From 88606ce990aaec910d1d09e54bd70e68b2ba3cff Mon Sep 17 00:00:00 2001 From: chinyixiang <112596208+chinyixiang@users.noreply.github.com> Date: Sun, 27 Apr 2025 22:46:29 +0800 Subject: [PATCH 1/3] Fix LLM-Tracer traces not found issue (#55) fixed llm-tracer tracer not found issue Signed-off-by: wwanarif --- studio-backend/app/routers/llmtraces_router.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/studio-backend/app/routers/llmtraces_router.py b/studio-backend/app/routers/llmtraces_router.py index 7b6565b..348b4cc 100644 --- a/studio-backend/app/routers/llmtraces_router.py +++ b/studio-backend/app/routers/llmtraces_router.py @@ -17,8 +17,9 @@ async def list_trace_ids(namespace: str): SELECT DISTINCT tts.TraceId, tts.Start, tts.End FROM otel.otel_traces_trace_id_ts AS tts INNER JOIN otel.otel_traces AS ot ON tts.TraceId = ot.TraceId - WHERE ot.ResourceAttributes['k8s.namespace.name'] = '%(namespace)s' + WHERE ot.ResourceAttributes['k8s.namespace.name'] = %(namespace)s """ + print(f"Query: {query}") result = client.execute(query, {'namespace': namespace}) if not result: From 1e46004647cef7c557480716f8f6ca9c4dd095a2 Mon Sep 17 00:00:00 2001 From: "Sun, Xuehao" Date: Wed, 7 May 2025 11:43:36 +0800 Subject: [PATCH 2/3] Add exempt-issue-labels configuration to check stale issue and PR workflow (#54) Signed-off-by: Sun, Xuehao --- .github/workflows/daily_check_issue_and_pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/daily_check_issue_and_pr.yml b/.github/workflows/daily_check_issue_and_pr.yml index 4664b25..21e1c1d 100644 --- a/.github/workflows/daily_check_issue_and_pr.yml +++ b/.github/workflows/daily_check_issue_and_pr.yml @@ -26,3 +26,4 @@ jobs: close-pr-message: "This PR was closed because it has been stalled for 7 days with no activity." repo-token: ${{ secrets.ACTION_TOKEN }} start-date: "2025-03-01T00:00:00Z" + exempt-issue-labels: "Backlog" From 7ecbc25e05b629b6606400fccd866f0cf2996813 Mon Sep 17 00:00:00 2001 From: cheehook Date: Sat, 28 Jun 2025 04:28:44 +0000 Subject: [PATCH 3/3] new app frontend to support multi examples adopted from productivity suite Signed-off-by: cheehook --- .gitignore | 3 +- app-frontend/{Dockerfile => Dockerfile.react} | 17 +- app-frontend/compose.yaml | 52 + app-frontend/react/.env.production | 9 +- app-frontend/react/.gitignore | 2 - app-frontend/react/env.sh | 10 +- app-frontend/react/index.html | 23 +- app-frontend/react/nginx.conf | 3 +- app-frontend/react/package.json | 108 +- app-frontend/react/postcss.config.cjs | 14 - .../favicon.ico} | 0 app-frontend/react/public/logo192.png | Bin 0 -> 8581 bytes app-frontend/react/public/logo512.png | Bin 0 -> 22920 bytes app-frontend/react/public/manifest.json | 15 + app-frontend/react/public/model_configs.json | 9 + app-frontend/react/public/robots.txt | 2 + app-frontend/react/public/vite.svg | 1 - app-frontend/react/src/App.scss | 43 +- app-frontend/react/src/App.tsx | 218 +++- app-frontend/react/src/assets/icons/moon.svg | 1 + .../assets/{ => icons}/opea-icon-black.svg | 0 .../src/assets/icons/opea-icon-color.svg | 40 + app-frontend/react/src/assets/icons/sun.svg | 4 + app-frontend/react/src/assets/react.svg | 1 - app-frontend/react/src/common/Sandbox.ts | 4 - app-frontend/react/src/common/client.ts | 8 - .../Chat_Assistant/ChatAssistant.module.scss | 68 ++ .../Chat_Assistant/ChatAssistant.tsx | 227 ++++ .../components/Chat_Markdown/ChatMarkdown.tsx | 127 ++ .../Chat_Markdown/CodeRender/CodeRender.tsx | 78 ++ .../CodeRender/codeRender.module.scss | 36 + .../Chat_Markdown/ThinkRender/ThinkCard.tsx | 29 + .../Chat_Markdown/markdown.module.scss | 29 + .../Chat_SettingsModal/ChatSettingsModal.tsx | 43 + .../Chat_Sources/ChatSources.module.scss | 47 + .../components/Chat_Sources/ChatSources.tsx | 28 + .../components/Chat_User/ChatUser.module.scss | 27 + .../src/components/Chat_User/ChatUser.tsx | 44 + .../Conversation/ConversationSideBar.tsx | 45 - .../components/Conversation/DataSource.tsx | 296 ----- .../Conversation/conversation.module.scss | 69 -- .../src/components/Data_Web/DataWebInput.tsx | 71 ++ .../components/DropDown/DropDown.module.scss | 63 + .../src/components/DropDown/DropDown.tsx | 118 ++ .../File_Display/FileDisplay.module.scss | 44 + .../components/File_Display/FileDisplay.tsx | 51 + .../File_Input/FileInput.module.scss | 69 ++ .../src/components/File_Input/FileInput.tsx | 393 ++++++ .../src/components/Header/Header.module.scss | 160 +++ .../react/src/components/Header/Header.tsx | 230 ++++ .../Header_DownloadChat/DownloadChat.tsx | 74 ++ .../ThemeToggle.module.scss | 65 + .../Header_ThemeToggle/ThemeToggle.tsx | 48 + .../Message/conversationMessage.module.scss | 15 - .../Message/conversationMessage.tsx | 133 -- .../components/Notification/Notification.tsx | 144 +++ .../PrimaryInput/PrimaryInput.module.scss | 44 + .../components/PrimaryInput/PrimaryInput.tsx | 200 +++ .../PrimaryInput_AudioInput/AudioInput.tsx | 85 ++ .../PromptSelector.module.scss | 87 ++ .../PromptSelector.tsx | 113 ++ .../components/ProgressIcon/ProgressIcon.tsx | 13 + .../PromptSettings/PromptSettings.module.scss | 89 ++ .../PromptSettings/PromptSettings.tsx | 268 ++++ .../PromptSettings_Slider/Slider.module.scss | 88 ++ .../PromptSettings_Slider/Slider.tsx | 49 + .../TokensInput.module.scss | 49 + .../PromptSettings_Tokens/TokensInput.tsx | 46 + .../SearchInput/SearchInput.module.scss | 17 + .../components/SearchInput/SearchInput.tsx | 63 + .../components/SideBar/SideBar.module.scss | 117 ++ .../react/src/components/SideBar/SideBar.tsx | 226 ++++ .../SideBar_UploadChat/UploadChat.tsx | 141 +++ .../Summary_WebInput/WebInput.module.scss | 19 + .../components/Summary_WebInput/WebInput.tsx | 120 ++ .../UserInfoModal/UserInfoModal.tsx | 55 - .../components/sidebar/sidebar.module.scss | 84 -- .../react/src/components/sidebar/sidebar.tsx | 70 -- app-frontend/react/src/config.ts | 64 +- .../react/src/contexts/ThemeContext.tsx | 39 + app-frontend/react/src/icons/Atom.tsx | 134 ++ app-frontend/react/src/icons/ChatBubble.tsx | 38 + app-frontend/react/src/icons/Database.tsx | 29 + app-frontend/react/src/icons/Recent.tsx | 29 + app-frontend/react/src/icons/Waiting.tsx | 45 + app-frontend/react/src/index.scss | 64 +- app-frontend/react/src/index.tsx | 24 + .../src/layouts/Main/MainLayout.module.scss | 21 + .../react/src/layouts/Main/MainLayout.tsx | 39 + .../layouts/Minimal/MinimalLayout.module.scss | 10 + .../src/layouts/Minimal/MinimalLayout.tsx | 13 + .../layouts/ProtectedRoute/ProtectedRoute.tsx | 29 + app-frontend/react/src/logo.svg | 40 + app-frontend/react/src/main.tsx | 17 - .../react/src/pages/Chat/ChatView.module.scss | 47 + .../react/src/pages/Chat/ChatView.tsx | 353 ++++++ .../DataSourceManagement.module.scss | 71 ++ .../pages/DataSource/DataSourceManagement.tsx | 242 ++++ .../src/pages/History/HistoryView.module.scss | 82 ++ .../react/src/pages/History/HistoryView.tsx | 214 ++++ .../react/src/pages/Home/Home.module.scss | 39 + app-frontend/react/src/pages/Home/Home.tsx | 111 ++ .../src/redux/Conversation/Conversation.ts | 101 +- .../redux/Conversation/ConversationSlice.ts | 1078 +++++++++++------ .../react/src/redux/Prompt/PromptSlice.ts | 96 ++ app-frontend/react/src/redux/User/user.d.ts | 6 +- .../react/src/redux/User/userSlice.ts | 18 +- app-frontend/react/src/redux/store.ts | 75 +- app-frontend/react/src/redux/thunkUtil.ts | 2 +- .../react/src/shared/ActionButtons.tsx | 94 ++ .../src/shared/ModalBox/Modal.module.scss | 50 + .../react/src/shared/ModalBox/ModalBox.tsx | 29 + .../react/src/styles/components/_context.scss | 0 .../react/src/styles/components/_sidebar.scss | 8 - .../react/src/styles/components/content.scss | 5 - .../src/styles/components/context.module.scss | 67 - .../react/src/styles/layout/_basics.scss | 7 - .../react/src/styles/layout/_flex.scss | 6 - app-frontend/react/src/styles/styles.scss | 5 - app-frontend/react/src/theme/theme.tsx | 456 +++++++ app-frontend/react/src/types/common.ts | 13 + app-frontend/react/src/types/conversation.ts | 57 + app-frontend/react/src/types/global.d.ts | 7 + app-frontend/react/src/types/speech.d.ts | 27 + app-frontend/react/src/types/styles.d.ts | 7 + app-frontend/react/src/types/theme.d.ts | 47 + app-frontend/react/src/utils/utils.js | 96 ++ app-frontend/react/src/vite-env.d.ts | 3 +- app-frontend/react/tsconfig.json | 39 +- app-frontend/react/vite.config.js | 120 ++ app-frontend/react/vite.config.ts | 38 - 131 files changed, 8298 insertions(+), 1554 deletions(-) rename app-frontend/{Dockerfile => Dockerfile.react} (51%) create mode 100644 app-frontend/compose.yaml delete mode 100644 app-frontend/react/postcss.config.cjs rename app-frontend/react/{src/assets/opea-icon-color.svg => public/favicon.ico} (100%) create mode 100644 app-frontend/react/public/logo192.png create mode 100644 app-frontend/react/public/logo512.png create mode 100644 app-frontend/react/public/manifest.json create mode 100644 app-frontend/react/public/model_configs.json create mode 100644 app-frontend/react/public/robots.txt delete mode 100644 app-frontend/react/public/vite.svg create mode 100644 app-frontend/react/src/assets/icons/moon.svg rename app-frontend/react/src/assets/{ => icons}/opea-icon-black.svg (100%) create mode 100644 app-frontend/react/src/assets/icons/opea-icon-color.svg create mode 100644 app-frontend/react/src/assets/icons/sun.svg delete mode 100644 app-frontend/react/src/assets/react.svg delete mode 100644 app-frontend/react/src/common/Sandbox.ts delete mode 100644 app-frontend/react/src/common/client.ts create mode 100644 app-frontend/react/src/components/Chat_Assistant/ChatAssistant.module.scss create mode 100644 app-frontend/react/src/components/Chat_Assistant/ChatAssistant.tsx create mode 100644 app-frontend/react/src/components/Chat_Markdown/ChatMarkdown.tsx create mode 100644 app-frontend/react/src/components/Chat_Markdown/CodeRender/CodeRender.tsx create mode 100644 app-frontend/react/src/components/Chat_Markdown/CodeRender/codeRender.module.scss create mode 100644 app-frontend/react/src/components/Chat_Markdown/ThinkRender/ThinkCard.tsx create mode 100644 app-frontend/react/src/components/Chat_Markdown/markdown.module.scss create mode 100644 app-frontend/react/src/components/Chat_SettingsModal/ChatSettingsModal.tsx create mode 100644 app-frontend/react/src/components/Chat_Sources/ChatSources.module.scss create mode 100644 app-frontend/react/src/components/Chat_Sources/ChatSources.tsx create mode 100644 app-frontend/react/src/components/Chat_User/ChatUser.module.scss create mode 100644 app-frontend/react/src/components/Chat_User/ChatUser.tsx delete mode 100644 app-frontend/react/src/components/Conversation/ConversationSideBar.tsx delete mode 100644 app-frontend/react/src/components/Conversation/DataSource.tsx delete mode 100644 app-frontend/react/src/components/Conversation/conversation.module.scss create mode 100644 app-frontend/react/src/components/Data_Web/DataWebInput.tsx create mode 100644 app-frontend/react/src/components/DropDown/DropDown.module.scss create mode 100644 app-frontend/react/src/components/DropDown/DropDown.tsx create mode 100644 app-frontend/react/src/components/File_Display/FileDisplay.module.scss create mode 100644 app-frontend/react/src/components/File_Display/FileDisplay.tsx create mode 100644 app-frontend/react/src/components/File_Input/FileInput.module.scss create mode 100644 app-frontend/react/src/components/File_Input/FileInput.tsx create mode 100644 app-frontend/react/src/components/Header/Header.module.scss create mode 100644 app-frontend/react/src/components/Header/Header.tsx create mode 100644 app-frontend/react/src/components/Header_DownloadChat/DownloadChat.tsx create mode 100644 app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.module.scss create mode 100644 app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.tsx delete mode 100644 app-frontend/react/src/components/Message/conversationMessage.module.scss delete mode 100644 app-frontend/react/src/components/Message/conversationMessage.tsx create mode 100644 app-frontend/react/src/components/Notification/Notification.tsx create mode 100644 app-frontend/react/src/components/PrimaryInput/PrimaryInput.module.scss create mode 100644 app-frontend/react/src/components/PrimaryInput/PrimaryInput.tsx create mode 100644 app-frontend/react/src/components/PrimaryInput_AudioInput/AudioInput.tsx create mode 100644 app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.module.scss create mode 100644 app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.tsx create mode 100644 app-frontend/react/src/components/ProgressIcon/ProgressIcon.tsx create mode 100644 app-frontend/react/src/components/PromptSettings/PromptSettings.module.scss create mode 100644 app-frontend/react/src/components/PromptSettings/PromptSettings.tsx create mode 100644 app-frontend/react/src/components/PromptSettings_Slider/Slider.module.scss create mode 100644 app-frontend/react/src/components/PromptSettings_Slider/Slider.tsx create mode 100644 app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.module.scss create mode 100644 app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.tsx create mode 100644 app-frontend/react/src/components/SearchInput/SearchInput.module.scss create mode 100644 app-frontend/react/src/components/SearchInput/SearchInput.tsx create mode 100644 app-frontend/react/src/components/SideBar/SideBar.module.scss create mode 100644 app-frontend/react/src/components/SideBar/SideBar.tsx create mode 100644 app-frontend/react/src/components/SideBar_UploadChat/UploadChat.tsx create mode 100644 app-frontend/react/src/components/Summary_WebInput/WebInput.module.scss create mode 100644 app-frontend/react/src/components/Summary_WebInput/WebInput.tsx delete mode 100644 app-frontend/react/src/components/UserInfoModal/UserInfoModal.tsx delete mode 100644 app-frontend/react/src/components/sidebar/sidebar.module.scss delete mode 100644 app-frontend/react/src/components/sidebar/sidebar.tsx create mode 100644 app-frontend/react/src/contexts/ThemeContext.tsx create mode 100644 app-frontend/react/src/icons/Atom.tsx create mode 100644 app-frontend/react/src/icons/ChatBubble.tsx create mode 100644 app-frontend/react/src/icons/Database.tsx create mode 100644 app-frontend/react/src/icons/Recent.tsx create mode 100644 app-frontend/react/src/icons/Waiting.tsx create mode 100644 app-frontend/react/src/index.tsx create mode 100644 app-frontend/react/src/layouts/Main/MainLayout.module.scss create mode 100644 app-frontend/react/src/layouts/Main/MainLayout.tsx create mode 100644 app-frontend/react/src/layouts/Minimal/MinimalLayout.module.scss create mode 100644 app-frontend/react/src/layouts/Minimal/MinimalLayout.tsx create mode 100644 app-frontend/react/src/layouts/ProtectedRoute/ProtectedRoute.tsx create mode 100644 app-frontend/react/src/logo.svg delete mode 100644 app-frontend/react/src/main.tsx create mode 100644 app-frontend/react/src/pages/Chat/ChatView.module.scss create mode 100644 app-frontend/react/src/pages/Chat/ChatView.tsx create mode 100644 app-frontend/react/src/pages/DataSource/DataSourceManagement.module.scss create mode 100644 app-frontend/react/src/pages/DataSource/DataSourceManagement.tsx create mode 100644 app-frontend/react/src/pages/History/HistoryView.module.scss create mode 100644 app-frontend/react/src/pages/History/HistoryView.tsx create mode 100644 app-frontend/react/src/pages/Home/Home.module.scss create mode 100644 app-frontend/react/src/pages/Home/Home.tsx create mode 100644 app-frontend/react/src/redux/Prompt/PromptSlice.ts create mode 100644 app-frontend/react/src/shared/ActionButtons.tsx create mode 100644 app-frontend/react/src/shared/ModalBox/Modal.module.scss create mode 100644 app-frontend/react/src/shared/ModalBox/ModalBox.tsx delete mode 100644 app-frontend/react/src/styles/components/_context.scss delete mode 100644 app-frontend/react/src/styles/components/_sidebar.scss delete mode 100644 app-frontend/react/src/styles/components/content.scss delete mode 100644 app-frontend/react/src/styles/components/context.module.scss delete mode 100644 app-frontend/react/src/styles/layout/_basics.scss delete mode 100644 app-frontend/react/src/styles/layout/_flex.scss delete mode 100644 app-frontend/react/src/styles/styles.scss create mode 100644 app-frontend/react/src/theme/theme.tsx create mode 100644 app-frontend/react/src/types/common.ts create mode 100644 app-frontend/react/src/types/conversation.ts create mode 100644 app-frontend/react/src/types/global.d.ts create mode 100644 app-frontend/react/src/types/speech.d.ts create mode 100644 app-frontend/react/src/types/styles.d.ts create mode 100644 app-frontend/react/src/types/theme.d.ts create mode 100644 app-frontend/react/src/utils/utils.js create mode 100644 app-frontend/react/vite.config.js delete mode 100644 app-frontend/react/vite.config.ts diff --git a/.gitignore b/.gitignore index ef79b3a..3546d91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ test-results/ **.log **/report.html -docker-compose \ No newline at end of file +docker-compose +**/node_modules/ diff --git a/app-frontend/Dockerfile b/app-frontend/Dockerfile.react similarity index 51% rename from app-frontend/Dockerfile rename to app-frontend/Dockerfile.react index 10255e9..4c4d727 100644 --- a/app-frontend/Dockerfile +++ b/app-frontend/Dockerfile.react @@ -2,21 +2,20 @@ # SPDX-License-Identifier: Apache-2.0 # Use node 20.11.1 as the base image -FROM node:latest AS vite-app - -COPY react /usr/app/react +FROM node:20.11.1 as vite-app + +COPY ./react /usr/app/react WORKDIR /usr/app/react -RUN npm install --legacy-peer-deps && npm run build -FROM nginx:1.27.4-alpine-slim +RUN ["npm", "install"] +RUN ["npm", "run", "build"] + -# Install uuidgen in the nginx:alpine image -RUN apk add --no-cache util-linux \ - && apk upgrade --no-cache +FROM nginx:alpine COPY --from=vite-app /usr/app/react/dist /usr/share/nginx/html COPY ./react/env.sh /docker-entrypoint.d/env.sh COPY ./react/nginx.conf /etc/nginx/conf.d/default.conf -RUN chmod +x /docker-entrypoint.d/env.sh \ No newline at end of file +RUN chmod +x /docker-entrypoint.d/env.sh diff --git a/app-frontend/compose.yaml b/app-frontend/compose.yaml new file mode 100644 index 0000000..31b3080 --- /dev/null +++ b/app-frontend/compose.yaml @@ -0,0 +1,52 @@ +# Copyright (C) 2024 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +services: + app-frontend: + image: app-frontend:ch + container_name: app-frontend + depends_on: + - chathistory-mongo + ports: + - 5175:80 + environment: + - no_proxy=${no_proxy} + - https_proxy=${https_proxy} + - http_proxy=${http_proxy} + - APP_BACKEND_SERVICE_URL=http://localhost:8888/v1/app-backend + - APP_DATAPREP_SERVICE_URL=http://localhost:6007/v1/dataprep + - APP_CHAT_HISTORY_SERVICE_URL=http://localhost:6012/v1/chathistory + - APP_UI_SELECTION=chat,summary,code + ipc: host + restart: always + + mongo: + image: mongo:7.0.11 + container_name: mongodb + ports: + - 27017:27017 + environment: + http_proxy: ${http_proxy} + https_proxy: ${https_proxy} + no_proxy: ${no_proxy} + command: mongod --quiet --logpath /dev/null + + chathistory-mongo: + image: ${REGISTRY:-opea}/chathistory-mongo:${TAG:-latest} + container_name: chathistory-mongo-server + ports: + - "6012:6012" + ipc: host + environment: + http_proxy: ${http_proxy} + no_proxy: ${no_proxy} + https_proxy: ${https_proxy} + MONGO_HOST: ${MONGO_HOST:-mongo} + MONGO_PORT: ${MONGO_PORT:-27017} + COLLECTION_NAME: ${COLLECTION_NAME:-Conversations} + LOGFLAG: ${LOGFLAG} + restart: unless-stopped + +networks: + default: + driver: bridge diff --git a/app-frontend/react/.env.production b/app-frontend/react/.env.production index 16b02d1..26f274d 100644 --- a/app-frontend/react/.env.production +++ b/app-frontend/react/.env.production @@ -1 +1,8 @@ -VITE_APP_UUID=APP_UUID \ No newline at end of file +VITE_BACKEND_SERVICE_URL=APP_BACKEND_SERVICE_URL +VITE_DATAPREP_SERVICE_URL=APP_DATAPREP_SERVICE_URL +VITE_CHAT_HISTORY_SERVICE_URL=APP_CHAT_HISTORY_SERVICE_URL +VITE_UI_SELECTION=APP_UI_SELECTION + +VITE_PROMPT_SERVICE_GET_ENDPOINT=APP_PROMPT_SERVICE_GET_ENDPOINT +VITE_PROMPT_SERVICE_CREATE_ENDPOINT=APP_PROMPT_SERVICE_CREATE_ENDPOINT +VITE_PROMPT_SERVICE_DELETE_ENDPOINT=APP_PROMPT_SERVICE_DELETE_ENDPOINT diff --git a/app-frontend/react/.gitignore b/app-frontend/react/.gitignore index 418b703..a547bf3 100644 --- a/app-frontend/react/.gitignore +++ b/app-frontend/react/.gitignore @@ -7,8 +7,6 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -# dependencies -package-lock.json node_modules dist dist-ssr diff --git a/app-frontend/react/env.sh b/app-frontend/react/env.sh index c87c502..ce1372e 100644 --- a/app-frontend/react/env.sh +++ b/app-frontend/react/env.sh @@ -2,12 +2,6 @@ # Copyright (C) 2024 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -# Generate a random UUID for the application -export APP_UUID=$(uuidgen) - -# Print the generated UUID for verification -echo "Generated UUID: $APP_UUID" - for i in $(env | grep APP_) #// Make sure to use the prefix MY_APP_ if you have any other prefix in env.production file variable name replace it with MY_APP_ do key=$(echo $i | cut -d '=' -f 1) @@ -16,6 +10,6 @@ do # sed All files # find /usr/share/nginx/html -type f -exec sed -i "s|${key}|${value}|g" '{}' + - # sed JS, CSS, and HTML files - find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) -exec sed -i "s|${key}|${value}|g" '{}' + + # sed JS and CSS only + find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' + done diff --git a/app-frontend/react/index.html b/app-frontend/react/index.html index d7e8864..0548818 100644 --- a/app-frontend/react/index.html +++ b/app-frontend/react/index.html @@ -1,18 +1,29 @@ - - - - Conversations UI + + + + + + + + + + + OPEA Studio APP +
- + diff --git a/app-frontend/react/nginx.conf b/app-frontend/react/nginx.conf index 77fd5da..01aef12 100644 --- a/app-frontend/react/nginx.conf +++ b/app-frontend/react/nginx.conf @@ -12,10 +12,9 @@ server { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html =404; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; location ~* \.(gif|jpe?g|png|webp|ico|svg|css|js|mp4|woff2)$ { expires 1d; } } -} \ No newline at end of file +} diff --git a/app-frontend/react/package.json b/app-frontend/react/package.json index 4dbfab3..1180caf 100644 --- a/app-frontend/react/package.json +++ b/app-frontend/react/package.json @@ -1,49 +1,85 @@ { - "name": "ui", + "name": "ProductivitySuite", + "version": "0.0.1", + "description": "ProductivitySuite UI - OPEA", + "homepage": ".", "private": true, - "version": "0.0.0", "type": "module", + "engines": { + "node": "20.x" + }, "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview", - "test": "vitest" + "dev": "vite --port 5173", + "build": "vite build", + "preview": "vite preview --port 5173", + "prettier:write": "prettier --write .", + "test": "vitest run" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] }, "dependencies": { - "@mantine/charts": "7.17.2", - "@mantine/core": "^7.17.2", - "@mantine/hooks": "^7.17.2", - "@mantine/notifications": "^7.17.2", "@microsoft/fetch-event-source": "^2.0.1", - "@reduxjs/toolkit": "^2.2.5", - "@tabler/icons-react": "3.7.0", - "axios": "^1.7.2", - "luxon": "^3.4.4", + "@mui/icons-material": "^6.4.1", + "@mui/material": "^6.4.1", + "@mui/styled-engine-sc": "^6.4.0", + "@reduxjs/toolkit": "^2.5.0", + "axios": "^1.7.9", + "notistack": "^3.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-redux": "^9.1.2", - "uuid": "^10.0.0" + "react-markdown": "^8.0.7", + "react-redux": "^9.2.0", + "react-router-dom": "^7.1.1", + "react-syntax-highlighter": "^15.6.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^3.0.1", + "styled-components": "^6.1.14" }, "devDependencies": { - "@testing-library/react": "^16.0.0", - "@types/luxon": "^3.4.2", - "@types/node": "^20.12.12", - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", - "@typescript-eslint/eslint-plugin": "^7.2.0", - "@typescript-eslint/parser": "^7.2.0", - "@vitejs/plugin-react": "^4.2.1", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.6", - "jsdom": "^24.1.0", - "postcss": "^8.4.38", - "postcss-preset-mantine": "^1.15.0", - "postcss-simple-vars": "^7.0.1", - "sass": "1.64.2", - "typescript": "^5.2.2", - "vite": "^5.2.13", - "vitest": "^1.6.0" + "@rollup/plugin-terser": "^0.4.4", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/jest": "^29.4.0", + "@types/node": "^18.13.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^7.6.0", + "cors": "^2.8.5", + "cross-env": "^7.0.3", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "nodemon": "^3.1.9", + "prettier": "^3.5.3", + "rollup-plugin-visualizer": "^5.14.0", + "sass": "^1.83.1", + "typescript": "^5.7.3", + "vite": "^5.3.1", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-mkcert": "^1.17.6", + "vite-plugin-sass-dts": "^1.3.30", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^3.1.2", + "wait-on": "^7.0.1", + "webpack-bundle-analyzer": "^4.10.2" } } diff --git a/app-frontend/react/postcss.config.cjs b/app-frontend/react/postcss.config.cjs deleted file mode 100644 index e817f56..0000000 --- a/app-frontend/react/postcss.config.cjs +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - plugins: { - "postcss-preset-mantine": {}, - "postcss-simple-vars": { - variables: { - "mantine-breakpoint-xs": "36em", - "mantine-breakpoint-sm": "48em", - "mantine-breakpoint-md": "62em", - "mantine-breakpoint-lg": "75em", - "mantine-breakpoint-xl": "88em", - }, - }, - }, -}; diff --git a/app-frontend/react/src/assets/opea-icon-color.svg b/app-frontend/react/public/favicon.ico similarity index 100% rename from app-frontend/react/src/assets/opea-icon-color.svg rename to app-frontend/react/public/favicon.ico diff --git a/app-frontend/react/public/logo192.png b/app-frontend/react/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fa313abf53936aefc517dbd583b724a57199d415 GIT binary patch literal 8581 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�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 b!B~?t+WWKS5u5)CcMJGaRz{_UZlwPKNp@r} literal 0 HcmV?d00001 diff --git a/app-frontend/react/public/manifest.json b/app-frontend/react/public/manifest.json new file mode 100644 index 0000000..14363bb --- /dev/null +++ b/app-frontend/react/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "OPEA Studio App", + "name": "OPEA Studio APP UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/app-frontend/react/public/model_configs.json b/app-frontend/react/public/model_configs.json new file mode 100644 index 0000000..cea98dc --- /dev/null +++ b/app-frontend/react/public/model_configs.json @@ -0,0 +1,9 @@ +[ + { + "model_name": "Intel/neural-chat-7b-v3-3", + "displayName": "Intel Neural Chat", + "minToken": 100, + "maxToken": 2000, + "types": ["chat", "summary", "code"] + } +] diff --git a/app-frontend/react/public/robots.txt b/app-frontend/react/public/robots.txt new file mode 100644 index 0000000..01b0f9a --- /dev/null +++ b/app-frontend/react/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/app-frontend/react/public/vite.svg b/app-frontend/react/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/app-frontend/react/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app-frontend/react/src/App.scss b/app-frontend/react/src/App.scss index 187764a..1317587 100644 --- a/app-frontend/react/src/App.scss +++ b/app-frontend/react/src/App.scss @@ -1,42 +1 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -@import "./styles/styles"; - -.root { - @include flex(row, nowrap, flex-start, flex-start); -} - -.layout-wrapper { - @include absolutes; - - display: grid; - - width: 100%; - height: 100%; - - grid-template-columns: 80px auto; - grid-template-rows: 1fr; -} - -/* ===== Scrollbar CSS ===== */ -/* Firefox */ -* { - scrollbar-width: thin; - scrollbar-color: #d6d6d6 #ffffff; -} - -/* Chrome, Edge, and Safari */ -*::-webkit-scrollbar { - width: 8px; -} - -*::-webkit-scrollbar-track { - background: #ffffff; -} - -*::-webkit-scrollbar-thumb { - background-color: #d6d6d6; - border-radius: 16px; - border: 4px double #dedede; -} +// Post javascript styles diff --git a/app-frontend/react/src/App.tsx b/app-frontend/react/src/App.tsx index 17ba06b..050b9ea 100644 --- a/app-frontend/react/src/App.tsx +++ b/app-frontend/react/src/App.tsx @@ -1,39 +1,179 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import "./App.scss" -import { MantineProvider } from "@mantine/core" -import '@mantine/notifications/styles.css'; -import { SideNavbar, SidebarNavList } from "./components/sidebar/sidebar" -import { IconMessages } from "@tabler/icons-react" -import UserInfoModal from "./components/UserInfoModal/UserInfoModal" -import Conversation from "./components/Conversation/Conversation" -import { Notifications } from '@mantine/notifications'; -// import { UiFeatures } from "./common/Sandbox"; -import { UI_FEATURES } from "./config"; - -// const dispatch = useAppDispatch(); - -const title = "OPEA Studio" -const navList: SidebarNavList = [ - { icon: IconMessages, label: title } -] - -function App() { - const enabledUiFeatures = UI_FEATURES; - - return ( - - - -

- -
- -
-
- - ) -} - -export default App +import "./App.scss"; + +import React, { Suspense, useEffect } from "react"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import ProtectedRoute from "@layouts/ProtectedRoute/ProtectedRoute"; + +import { setUser, userSelector } from "@redux/User/userSlice"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + conversationSelector, + getAllConversations, + getSupportedModels, + getSupportedUseCases, +} from "@redux/Conversation/ConversationSlice"; +import { getPrompts } from "@redux/Prompt/PromptSlice"; + +import MainLayout from "@layouts/Main/MainLayout"; +import MinimalLayout from "@layouts/Minimal/MinimalLayout"; +import Notification from "@components/Notification/Notification"; +import { Box, styled, Typography } from "@mui/material"; +// import { AtomIcon } from "@icons/Atom"; + +import Home from "@pages/Home/Home"; +import ChatView from "@pages/Chat/ChatView"; + +const HistoryView = React.lazy(() => import("@pages/History/HistoryView")); +const DataSourceManagement = React.lazy( + () => import("@pages/DataSource/DataSourceManagement") +); + +const LoadingBox = styled(Box)({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + height: "100vh", + width: "100vw", +}); + +const App = () => { + const dispatch = useAppDispatch(); + const { name, isAuthenticated } = useAppSelector(userSelector); + const { useCase } = useAppSelector(conversationSelector); + + useEffect(() => { + // Set static admin user + dispatch( + setUser({ + name: "admin", + isAuthenticated: true, + role: "Admin", + }) + ); + }, [dispatch]); + + const initSettings = () => { + if (isAuthenticated) { + dispatch(getSupportedUseCases()); + dispatch(getSupportedModels()); + dispatch(getPrompts()); + } + }; + + useEffect(() => { + if (isAuthenticated) initSettings(); + }, [isAuthenticated]); + + useEffect(() => { + // if (isAuthenticated && useCase) { + // dispatch(getAllConversations({ user: name, useCase: useCase })); + // } + dispatch(getAllConversations({ user: name})); + + console.log ("on reload") + }, [useCase, name, isAuthenticated]); + + return ( + + + {/* Routes wrapped in MainLayout */} + }> + + } + /> + + + }> + + } + /> + + + }> + ( + + )} + /> + } + /> + ( + + )} + /> + } + /> + + + }> + + } + /> + + } + /> + + } + /> + + } + /> + + + {/* Routes not wrapped in MainLayout */} + }> + {/* } /> */} + + + + + ); +}; + +export default App; \ No newline at end of file diff --git a/app-frontend/react/src/assets/icons/moon.svg b/app-frontend/react/src/assets/icons/moon.svg new file mode 100644 index 0000000..a9f36a8 --- /dev/null +++ b/app-frontend/react/src/assets/icons/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app-frontend/react/src/assets/opea-icon-black.svg b/app-frontend/react/src/assets/icons/opea-icon-black.svg similarity index 100% rename from app-frontend/react/src/assets/opea-icon-black.svg rename to app-frontend/react/src/assets/icons/opea-icon-black.svg diff --git a/app-frontend/react/src/assets/icons/opea-icon-color.svg b/app-frontend/react/src/assets/icons/opea-icon-color.svg new file mode 100644 index 0000000..7901511 --- /dev/null +++ b/app-frontend/react/src/assets/icons/opea-icon-color.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-frontend/react/src/assets/icons/sun.svg b/app-frontend/react/src/assets/icons/sun.svg new file mode 100644 index 0000000..510dad6 --- /dev/null +++ b/app-frontend/react/src/assets/icons/sun.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app-frontend/react/src/assets/react.svg b/app-frontend/react/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/app-frontend/react/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app-frontend/react/src/common/Sandbox.ts b/app-frontend/react/src/common/Sandbox.ts deleted file mode 100644 index eee8539..0000000 --- a/app-frontend/react/src/common/Sandbox.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type UiFeatures = { - dataprep: boolean; - chat: boolean; - }; \ No newline at end of file diff --git a/app-frontend/react/src/common/client.ts b/app-frontend/react/src/common/client.ts deleted file mode 100644 index 7512f73..0000000 --- a/app-frontend/react/src/common/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import axios from "axios"; - -//add iterceptors to add any request headers - -export default axios; diff --git a/app-frontend/react/src/components/Chat_Assistant/ChatAssistant.module.scss b/app-frontend/react/src/components/Chat_Assistant/ChatAssistant.module.scss new file mode 100644 index 0000000..ac8428e --- /dev/null +++ b/app-frontend/react/src/components/Chat_Assistant/ChatAssistant.module.scss @@ -0,0 +1,68 @@ +.chatReply { + display: flex; + flex-direction: row; + + .icon { + padding-right: 1rem; + + svg { + width: 24px; + height: 24px; + } + } +} + +.ellipsis { + position: relative; + + span { + position: relative; + animation: dance 1.5s infinite ease-in-out; + } + + span:nth-child(1) { + margin-left: 2px; + animation-delay: 0s; + } + + span:nth-child(2) { + animation-delay: 0.3s; + } + + span:nth-child(3) { + animation-delay: 0.6s; + } +} + +@keyframes dance { + 0%, + 100% { + bottom: 0; + opacity: 1; + } + 20% { + bottom: 5px; + opacity: 0.7; + } + 40% { + bottom: 0; + opacity: 1; + } +} + +.textedit { + width: 100%; + min-height: 50px; + padding: 1rem; +} + +.chatPrompt { + width: 100%; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + + p:first-of-type { + margin-top: 0; + } +} diff --git a/app-frontend/react/src/components/Chat_Assistant/ChatAssistant.tsx b/app-frontend/react/src/components/Chat_Assistant/ChatAssistant.tsx new file mode 100644 index 0000000..9f00433 --- /dev/null +++ b/app-frontend/react/src/components/Chat_Assistant/ChatAssistant.tsx @@ -0,0 +1,227 @@ +import React, { useEffect, useRef, useState } from "react"; + +import styles from "./ChatAssistant.module.scss"; +import { + Button, + Typography, + IconButton, + Box, + styled, + Tooltip, +} from "@mui/material"; +import { AtomIcon } from "@icons/Atom"; +import ThumbUpIcon from "@mui/icons-material/ThumbUp"; +import ThumbUpOutlinedIcon from "@mui/icons-material/ThumbUpOutlined"; +import ThumbDownIcon from "@mui/icons-material/ThumbDown"; +import ThumbDownOutlinedIcon from "@mui/icons-material/ThumbDownOutlined"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import EditNoteIcon from "@mui/icons-material/EditNote"; +// import ChatSettingsModal from "@components/Chat_SettingsModal/ChatSettingsModal"; + +import { + NotificationSeverity, + notify, +} from "@components/Notification/Notification"; +import { ChatMessageProps, Message } from "@redux/Conversation/Conversation"; +import ChatMarkdown from "@components/Chat_Markdown/ChatMarkdown"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + conversationSelector, + // saveConversationtoDatabase, + setSelectedConversationHistory, +} from "@redux/Conversation/ConversationSlice"; +import WaitingIcon from "@icons/Waiting"; + +const CancelStyle = styled(Button)(({ theme }) => ({ + ...theme.customStyles.actionButtons.delete, +})); + +const SaveStyle = styled(Button)(({ theme }) => ({ + ...theme.customStyles.actionButtons.solid, +})); + +const ChatAssistant: React.FC = ({ + message, + pending = false, +}) => { + const dispatch = useAppDispatch(); + const { + onGoingResult, + selectedConversationHistory, + selectedConversationId, + type, + } = useAppSelector(conversationSelector); + + const [currentMessage, setCurrentMessage] = useState(message); + const [editResponse, setEditResponse] = useState(false); + const responseRef = useRef(currentMessage.content); + const [disabledSave, setDisabledSave] = useState(false); + const [inputHeight, setInputHeight] = useState(0); + const heightCheck = useRef(null); + const isClipboardAvailable = navigator.clipboard && window.isSecureContext; + + useEffect(() => { + setCurrentMessage(message); + }, [message]); + + const assistantMessage = currentMessage.content ?? ""; + + // const [feedback, setFeedback] = useState( + // currentMessage.feedback?.is_thumbs_up === true ? true : currentMessage.feedback?.is_thumbs_up === false ? false : null + // ); + + // const submitFeedback = (thumbsUp: boolean) => { + // setFeedback(thumbsUp); + // notify('Feedback Submitted', NotificationSeverity.SUCCESS); + // // MessageService.submitFeedback({ id: currentMessage.message_id, feedback: {is_thumbs_up: thumbsUp}, useCase: selectedUseCase.use_case }); + // }; + + const copyText = (text: string) => { + navigator.clipboard.writeText(text); + notify("Copied to clipboard", NotificationSeverity.SUCCESS); + }; + + const modifyResponse = () => { + if (heightCheck.current) { + let updateHeight = heightCheck.current.offsetHeight; + setInputHeight(updateHeight); + setEditResponse(true); + } + }; + + const updateResponse = (response: string) => { + responseRef.current = response; + setDisabledSave(response === ""); + }; + + const saveResponse = () => { + const convoClone: Message[] = selectedConversationHistory.map( + (messageItem) => { + if (messageItem.time === currentMessage.time) { + return { + ...messageItem, + content: responseRef.current, + }; + } + return messageItem; + }, + ); + + dispatch(setSelectedConversationHistory(convoClone)); + // dispatch( + // saveConversationtoDatabase({ + // conversation: { id: selectedConversationId }, + // }), + // ); + + setInputHeight(0); + setEditResponse(false); + setDisabledSave(false); + }; + + const cancelResponse = () => { + setEditResponse(false); + }; + + const displayCurrentMessage = () => { + if (currentMessage.content) { + if (editResponse) { + return ( +
+ + + + Save + + Cancel +
+ ); + } else { + return ( + + + + ); + } + } else { + return ( + + Generating response + + . + . + . + + + ); + } + }; + + const displayMessageActions = () => { + if (onGoingResult) return; + + return ( + + {/*TODO: feedback support */} + {/* submitFeedback(true)}> + {feedback === null || feedback === false ? ( + + ) : ( + + )} + + + submitFeedback(false)}> + {feedback === null || feedback === true ? ( + + ) : ( + + )} + */} + + {/* */} + + {isClipboardAvailable && ( + + copyText(assistantMessage)}> + + + + )} + + {type === "chat" && ( + + + + + + )} + + ); + }; + + return ( +
+
+ +
+ +
+ {displayCurrentMessage()} + + {!pending && displayMessageActions()} +
+
+ ); +}; + +export default ChatAssistant; diff --git a/app-frontend/react/src/components/Chat_Markdown/ChatMarkdown.tsx b/app-frontend/react/src/components/Chat_Markdown/ChatMarkdown.tsx new file mode 100644 index 0000000..9666c7f --- /dev/null +++ b/app-frontend/react/src/components/Chat_Markdown/ChatMarkdown.tsx @@ -0,0 +1,127 @@ +import React, { lazy, Suspense, useEffect, useState } from "react"; +import markdownStyles from "./markdown.module.scss"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkBreaks from "remark-breaks"; +import ThinkCard from "./ThinkRender/ThinkCard"; +import { Button, Collapse, Box } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; + +const CodeRender = lazy(() => import("./CodeRender/CodeRender")); + +type MarkdownProps = { + content: string; +}; + +const extractThinkBlocks = (markdown: string): { cleaned: string; thinks: string[] } => { + const thinkRegex = /([\s\S]*?)<\/think>/g; + const thinks: string[] = []; + let cleaned = markdown; + let match; + + while ((match = thinkRegex.exec(markdown)) !== null) { + thinks.push(match[1].trim()); + } + + cleaned = markdown.replace(thinkRegex, "").trim(); + + return { cleaned, thinks }; +}; + +const ChatMarkdown = ({ content }: MarkdownProps) => { + useEffect(() => { + import("./CodeRender/CodeRender"); + }, []); + + const { cleaned, thinks } = extractThinkBlocks( + content.replace(/\\\\n/g, "\n").replace(/\\n/g, "\n") + ); + + const [showThinks, setShowThinks] = useState(false); + + return ( +
+ {thinks.length > 0 && ( + + + + + {thinks.map((block, idx) => ( + + ))} + + + + )} + + { + const hasBlockElement = React.Children.toArray(children).some( + (child) => + React.isValidElement(child) && + typeof child.type === "string" && + ["div", "h1", "h2", "h3", "ul", "ol", "table"].includes(child.type) + ); + return hasBlockElement ? ( + <>{children} + ) : ( +

+ {children} +

+ ); + }, + a: ({ children, ...props }) => ( + //@ts-ignore + + {children} + + ), + table: ({ children, ...props }) => ( +
+ {children}
+
+ ), + code({ inline, className, children }) { + const lang = /language-(\w+)/.exec(className || ""); + return ( + Loading Code Block...}> + {/*@ts-ignore*/} + + + ); + }, + }} + /> +
+ ); +}; + +export default ChatMarkdown; diff --git a/app-frontend/react/src/components/Chat_Markdown/CodeRender/CodeRender.tsx b/app-frontend/react/src/components/Chat_Markdown/CodeRender/CodeRender.tsx new file mode 100644 index 0000000..3fb833c --- /dev/null +++ b/app-frontend/react/src/components/Chat_Markdown/CodeRender/CodeRender.tsx @@ -0,0 +1,78 @@ +import styles from "./codeRender.module.scss"; +import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + atomOneDark, + atomOneLight, +} from "react-syntax-highlighter/dist/esm/styles/hljs"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { IconButton, styled, Tooltip, useTheme } from "@mui/material"; +import { + NotificationSeverity, + notify, +} from "@components/Notification/Notification"; + +const TitleBox = styled("div")(({ theme }) => ({ + background: theme.customStyles.code?.primary, + color: theme.customStyles.code?.title, +})); + +const StyledCode = styled(SyntaxHighlighter)(({ theme }) => ({ + background: theme.customStyles.code?.secondary + " !important", +})); + +type CodeRenderProps = { + cleanCode: React.ReactNode; + language: string; + inline: boolean; +}; +const CodeRender = ({ cleanCode, language, inline }: CodeRenderProps) => { + const theme = useTheme(); + + const isClipboardAvailable = navigator.clipboard && window.isSecureContext; + + cleanCode = String(cleanCode) + .replace(/\n$/, "") + .replace(/^\s*[\r\n]/gm, ""); //right trim and remove empty lines from the input + + const copyText = (text: string) => { + navigator.clipboard.writeText(text); + notify("Copied to clipboard", NotificationSeverity.SUCCESS); + }; + + try { + return inline ? ( + + {cleanCode} + + ) : ( +
+ +
+ {language || "language not detected"} +
+
+ {isClipboardAvailable && ( + + copyText(cleanCode.toString())}> + + + + )} +
+
+ +
+ ); + } catch (err) { + return
{cleanCode}
; + } +}; + +export default CodeRender; diff --git a/app-frontend/react/src/components/Chat_Markdown/CodeRender/codeRender.module.scss b/app-frontend/react/src/components/Chat_Markdown/CodeRender/codeRender.module.scss new file mode 100644 index 0000000..5960048 --- /dev/null +++ b/app-frontend/react/src/components/Chat_Markdown/CodeRender/codeRender.module.scss @@ -0,0 +1,36 @@ +.code { + margin: 7px 0px; + + .codeHead { + padding: 0px 10px !important; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + + .codeTitle { + } + + .codeActionGroup { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + } + } + + .codeHighlighterDiv { + margin: 0px !important; + white-space: pre-wrap !important; + + code { + white-space: pre-wrap !important; + } + } +} + +.inlineCode { + background: #fff; +} diff --git a/app-frontend/react/src/components/Chat_Markdown/ThinkRender/ThinkCard.tsx b/app-frontend/react/src/components/Chat_Markdown/ThinkRender/ThinkCard.tsx new file mode 100644 index 0000000..74db261 --- /dev/null +++ b/app-frontend/react/src/components/Chat_Markdown/ThinkRender/ThinkCard.tsx @@ -0,0 +1,29 @@ +// components/ThinkCard.tsx +import { Card, CardContent, Typography } from "@mui/material"; + +type ThinkCardProps = { + content: string; +}; + +const ThinkCard = ({ content }: ThinkCardProps) => { + return ( + + + + {content} + + + + ); +}; + +export default ThinkCard; diff --git a/app-frontend/react/src/components/Chat_Markdown/markdown.module.scss b/app-frontend/react/src/components/Chat_Markdown/markdown.module.scss new file mode 100644 index 0000000..e86902e --- /dev/null +++ b/app-frontend/react/src/components/Chat_Markdown/markdown.module.scss @@ -0,0 +1,29 @@ +.tableDiv { + &:first-of-type { + padding-top: 0px !important; + } + + table, + th, + td { + border: 1px solid black; + border-collapse: collapse; + padding: 5px; + } +} + +.md { + li { + margin-left: 35px; /* Adjust the value based on your preference */ + } +} + +.markdownWrapper { + > p:first-of-type { + margin-top: 0.25rem; + } + + > p:last-of-type { + margin-bottom: 0.25rem; + } +} diff --git a/app-frontend/react/src/components/Chat_SettingsModal/ChatSettingsModal.tsx b/app-frontend/react/src/components/Chat_SettingsModal/ChatSettingsModal.tsx new file mode 100644 index 0000000..732e5a2 --- /dev/null +++ b/app-frontend/react/src/components/Chat_SettingsModal/ChatSettingsModal.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { + Box, + Typography, + Modal, + IconButton, + styled, + Tooltip, +} from "@mui/material"; +import SettingsApplicationsOutlinedIcon from "@mui/icons-material/SettingsApplicationsOutlined"; +import PromptSettings from "@components/PromptSettings/PromptSettings"; +import { Close } from "@mui/icons-material"; +import ModalBox from "@root/shared/ModalBox/ModalBox"; + +const ChatSettingsModal = () => { + const [open, setOpen] = React.useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + return ( +
+ + + + + + + + + Response Settings + setOpen(false)}> + + + + + + + +
+ ); +}; + +export default ChatSettingsModal; diff --git a/app-frontend/react/src/components/Chat_Sources/ChatSources.module.scss b/app-frontend/react/src/components/Chat_Sources/ChatSources.module.scss new file mode 100644 index 0000000..1a6a0d7 --- /dev/null +++ b/app-frontend/react/src/components/Chat_Sources/ChatSources.module.scss @@ -0,0 +1,47 @@ +.sourceWrapper { + display: flex; + flex-direction: row; + justify-content: flex-end; + flex-wrap: wrap; + width: var(--content-width); + margin: 0 auto var(--vertical-spacer); + max-width: 100%; +} + +.iconWrap { + border: none; + border-radius: 6px; + margin-right: 0.5rem; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.sourceBox { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-left: 1rem; + padding: 5px; + border-radius: 6px; + margin-bottom: 1rem; +} + +.title { + margin: 0 0.5rem 0 0; + white-space: nowrap; + display: inline-block; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 400; +} + +.chip { + border-radius: 8px; + padding: 3px; + font-size: 12px; +} diff --git a/app-frontend/react/src/components/Chat_Sources/ChatSources.tsx b/app-frontend/react/src/components/Chat_Sources/ChatSources.tsx new file mode 100644 index 0000000..2bf0858 --- /dev/null +++ b/app-frontend/react/src/components/Chat_Sources/ChatSources.tsx @@ -0,0 +1,28 @@ +import { Box } from "@mui/material"; +import { conversationSelector } from "@redux/Conversation/ConversationSlice"; +import { useAppSelector } from "@redux/store"; +import styles from "./ChatSources.module.scss"; +import FileDispaly from "@components/File_Display/FileDisplay"; + +const ChatSources: React.FC = () => { + const { sourceLinks, sourceFiles, sourceType } = + useAppSelector(conversationSelector); + const isWeb = sourceType === "web"; + const sourceElements = isWeb ? sourceLinks : sourceFiles; + + if (sourceLinks.length === 0 && sourceFiles.length === 0) return; + + const renderElements = () => { + return sourceElements.map((element: any, elementIndex) => { + return ( + + + + ); + }); + }; + + return {renderElements()}; +}; + +export default ChatSources; diff --git a/app-frontend/react/src/components/Chat_User/ChatUser.module.scss b/app-frontend/react/src/components/Chat_User/ChatUser.module.scss new file mode 100644 index 0000000..3a5b507 --- /dev/null +++ b/app-frontend/react/src/components/Chat_User/ChatUser.module.scss @@ -0,0 +1,27 @@ +.userWrapper { + display: flex; + justify-content: flex-end; + margin-bottom: 2rem; + position: relative; + + .userPrompt { + max-width: 80%; + border-radius: var(--input-radius); + padding: 0.75rem 2rem 0.75rem 1rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + } + + .addIcon { + position: absolute; + right: -16px; + top: 3px; + opacity: 0; + transition: opacity 0.3s; + } + + &:hover .addIcon { + opacity: 1; + } +} diff --git a/app-frontend/react/src/components/Chat_User/ChatUser.tsx b/app-frontend/react/src/components/Chat_User/ChatUser.tsx new file mode 100644 index 0000000..8f08436 --- /dev/null +++ b/app-frontend/react/src/components/Chat_User/ChatUser.tsx @@ -0,0 +1,44 @@ +import { IconButton, styled, Tooltip } from "@mui/material"; +import React from "react"; +import styles from "./ChatUser.module.scss"; +import AddCircle from "@mui/icons-material/AddCircle"; +import { useAppDispatch } from "@redux/store"; +// import { addPrompt } from "@redux/Prompt/PromptSlice"; +import ChatMarkdown from "@components/Chat_Markdown/ChatMarkdown"; + +interface ChatUserProps { + content: string; +} + +const UserInput = styled("div")(({ theme }) => ({ + background: theme.customStyles.user?.main, +})); + +const AddIcon = styled(AddCircle)(({ theme }) => ({ + path: { + fill: theme.customStyles.icon?.main, + }, +})); + +const ChatUser: React.FC = ({ content }) => { + const dispatch = useAppDispatch(); + + // const sharePrompt = () => { + // dispatch(addPrompt({ promptText: content })); + // }; + + return ( +
+ + + + {/* + + + + */} +
+ ); +}; + +export default ChatUser; diff --git a/app-frontend/react/src/components/Conversation/ConversationSideBar.tsx b/app-frontend/react/src/components/Conversation/ConversationSideBar.tsx deleted file mode 100644 index 12591ad..0000000 --- a/app-frontend/react/src/components/Conversation/ConversationSideBar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import { ScrollAreaAutosize, Title } from "@mantine/core" - -import contextStyles from "../../styles/components/context.module.scss" -import { useAppDispatch, useAppSelector } from "../../redux/store" -import { conversationSelector, setSelectedConversationId } from "../../redux/Conversation/ConversationSlice" -// import { userSelector } from "../../redux/User/userSlice" - -export interface ConversationContextProps { - title: string -} - -export function ConversationSideBar({ title }: ConversationContextProps) { - const { conversations, selectedConversationId } = useAppSelector(conversationSelector) - // const user = useAppSelector(userSelector) - const dispatch = useAppDispatch() - - const conversationList = conversations?.map((curr) => ( -
{ - event.preventDefault() - dispatch(setSelectedConversationId(curr.conversationId)) - // dispatch(getConversationById({ user, conversationId: curr.conversationId })) - }} - key={curr.conversationId} - > -
{curr.title}
-
- )) - - return ( -
- - {title} - - -
{conversationList}
-
-
- ) -} diff --git a/app-frontend/react/src/components/Conversation/DataSource.tsx b/app-frontend/react/src/components/Conversation/DataSource.tsx deleted file mode 100644 index 22e87df..0000000 --- a/app-frontend/react/src/components/Conversation/DataSource.tsx +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import { ActionIcon, Button, Container, Drawer, FileInput, Loader, rem, Table, Text, TextInput } from '@mantine/core' -import { IconCheck, IconExclamationCircle, IconFileXFilled } from '@tabler/icons-react'; -import { SyntheticEvent, useState, useEffect } from 'react' -import { useAppDispatch, useAppSelector } from '../../redux/store' -import { submitDataSourceURL, addFileDataSource, updateFileDataSourceStatus, uploadFile, fileDataSourcesSelector, FileDataSource, clearFileDataSources } from '../../redux/Conversation/ConversationSlice'; -import { getCurrentTimeStamp, uuidv4 } from "../../common/util"; -import client from "../../common/client"; -import { DATA_PREP_URL } from "../../config"; - -type Props = { - opened: boolean - onClose: () => void -} -interface getFileListApiResponse { - name: string; - id: string; - type: string; - parent: string; -} - -export default function DataSource({ opened, onClose }: Props) { - const title = "Data Source" - const [file, setFile] = useState(); - const [fileList, setFileList] = useState([]); - const [isFile, setIsFile] = useState(true); - const [deleteSpinner, setDeleteSpinner] = useState(false); - const [url, setURL] = useState(""); - const dispatch = useAppDispatch(); - const fileDataSources = useAppSelector(fileDataSourcesSelector); - - const getFileList = async () => { - - try { - setTimeout(async () => { - const response = await client.post( - `${DATA_PREP_URL}/get`, - {}, // Request body (if needed, replace the empty object with actual data) - { - headers: { - 'Content-Type': 'application/json', - }, - }); - - setFileList(response.data); - }, 1500); - } - catch (error) { - console.error("Error fetching file data:", error); - } - }; - - const deleteFile = async (id: string) => { - try { - await client.post( - `${DATA_PREP_URL}/delete`, - { file_path: id }, // Request body (if needed, replace the empty object with actual data) - { - headers: { - 'Content-Type': 'application/json', - }, - }); - - getFileList(); - } - catch (error) { - console.error("Error fetching file data:", error); - } - setDeleteSpinner(false); - } - - const handleFileUpload = () => { - if (file){ - const id = uuidv4(); - dispatch(addFileDataSource({ id, source: [file.name], type: 'Files', startTime: getCurrentTimeStamp() })); - dispatch(updateFileDataSourceStatus({ id, status: 'uploading' })); - dispatch(uploadFile({ file })) - .then((response) => { - // Handle successful upload - if (response.payload && response.payload.status === 200) { - console.log("Upload successful:", response); - getFileList(); - dispatch(updateFileDataSourceStatus({ id, status: 'uploaded' })); - } - else { - console.error("Upload failed:", response); - getFileList(); - dispatch(updateFileDataSourceStatus({ id, status: 'failed' })); - } - }) - .catch((error) => { - // Handle failed upload - console.error("Upload failed:", error); - getFileList(); - dispatch(updateFileDataSourceStatus({ id, status: 'failed' })); - }); - }; - getFileList(); - } - - const handleChange = (event: SyntheticEvent) => { - event.preventDefault() - setURL((event.target as HTMLTextAreaElement).value) - } - - const handleSubmit = () => { - const id = uuidv4(); - dispatch(addFileDataSource({ id, source: url.split(";"), type: 'URLs', startTime: getCurrentTimeStamp() })); - dispatch(updateFileDataSourceStatus({ id, status: 'uploading' })); - dispatch(submitDataSourceURL({ link_list: url.split(";") })) - .then((response) => { - // Handle successful upload - if (response.payload && response.payload.status === 200) { - console.log("Upload successful:", response); - getFileList(); - - dispatch(updateFileDataSourceStatus({ id, status: 'uploaded' })); - } - else { - console.error("Upload failed:", response); - getFileList(); - - dispatch(updateFileDataSourceStatus({ id, status: 'failed' })); - } - }) - .catch((error) => { - // Handle failed upload - console.error("Upload failed:", error); - getFileList(); - dispatch(updateFileDataSourceStatus({ id, status: 'failed' })); - }); - } - - useEffect(() => { - let isFetching = false; // Flag to track if the function is in progress - getFileList(); - const interval = setInterval(async () => { - if (!isFetching) { - isFetching = true; - await getFileList(); // Wait for the function to complete - isFetching = false; - } - }, 20000); // 2000 ms = 2 seconds - - // Clear the interval when the component unmounts - return () => clearInterval(interval); - }, []); - - - return ( - - - {title} - - - Please upload your local file or paste a remote file link, and Chat will respond based on the content of the uploaded file. - - - - - - - - - - - -
- {isFile ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- - - Upload Job Queue - - - - - - - - - - - - {fileDataSources.map((item: FileDataSource, index:number) => ( - - - - - - - ))} - -
IDTypeStart TimeStatus
{index+1}{item.type} - {new Date(item.startTime*1000).toLocaleString('en-GB', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - })} - { - item.status === 'pending' ? - () : item.status === 'uploading' ? - () : item.status === 'uploaded' ? - ( - - - - ) : ( - - ) - - }
- -
- - - Uploaded Data Sources - - - - - - - - - - - {fileList.map((item: getFileListApiResponse, index:number) => ( - - - - - - ))} - -
IDSource NameAction
{index+1} - {item.id.length > 40 ? item.id.slice(0, 36) + '...' : item.id} - - -
-
-
- ) -} \ No newline at end of file diff --git a/app-frontend/react/src/components/Conversation/conversation.module.scss b/app-frontend/react/src/components/Conversation/conversation.module.scss deleted file mode 100644 index 3d0c1a0..0000000 --- a/app-frontend/react/src/components/Conversation/conversation.module.scss +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -@import "../../styles/styles"; - -.spacer { - flex: 1 1 auto; -} - -.conversationWrapper { - @include flex(row, nowrap, flex-start, flex-start); - flex: 1 1 auto; - height: 100%; - & > * { - height: 100%; - } - .conversationContent { - flex: 1 1 auto; - position: relative; - .conversationContentMessages { - @include absolutes; - display: grid; - grid-template-areas: - "header" - "messages" - "sliders" - "inputs"; - grid-template-columns: auto; - grid-template-rows: 60px auto min-content 125px; /* Adjusted for flexibility */ - - .conversationTitle { - grid-area: header; - @include flex(row, nowrap, center, flex-start); - height: 60px; - padding: 8px 24px; - border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); - } - - .historyContainer { - grid-area: messages; - overflow: auto; - width: 100%; - padding: 16px 32px; - & > * { - width: 100%; - } - } - - .conversatioSliders { - grid-area: sliders; - padding: 18px; - border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); - min-height: 50px; /* Ensure the area doesn't collapse */ - } - - .conversationActions { - grid-area: inputs; - padding: 18px; - border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); - } - } - - .conversationSplash { - @include absolutes; - @include flex(column, nowrap, center, center); - font-size: 32px; - } - } -} diff --git a/app-frontend/react/src/components/Data_Web/DataWebInput.tsx b/app-frontend/react/src/components/Data_Web/DataWebInput.tsx new file mode 100644 index 0000000..ae54cfc --- /dev/null +++ b/app-frontend/react/src/components/Data_Web/DataWebInput.tsx @@ -0,0 +1,71 @@ +import ProgressIcon from "@components/ProgressIcon/ProgressIcon"; +import { + CustomTextInput, + AddIcon, +} from "@components/Summary_WebInput/WebInput"; +import styles from "@components/Summary_WebInput/WebInput.module.scss"; +import { Box, InputAdornment } from "@mui/material"; +import { + conversationSelector, + submitDataSourceURL, +} from "@redux/Conversation/ConversationSlice"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { useEffect, useState } from "react"; + +const DataWebInput = () => { + const { dataSourceUrlStatus } = useAppSelector(conversationSelector); + const [inputValue, setInputValue] = useState(""); + const [uploading, setUploading] = useState(false); + const dispatch = useAppDispatch(); + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue) { + handleAdd(inputValue); + } + }; + + const handleAdd = (newSource: string) => { + dispatch(submitDataSourceURL({ link_list: [newSource] })); + setInputValue(""); + }; + + const handleIconClick = () => { + if (inputValue) { + handleAdd(inputValue); + } + }; + + useEffect(() => { + setUploading(dataSourceUrlStatus === "pending"); + }, [dataSourceUrlStatus]); + + return ( + + ) => + setInputValue(e.target.value) + } + InputProps={{ + endAdornment: !uploading ? ( + + + + ) : ( + + + + ), + }} + fullWidth + /> + + ); +}; + +export default DataWebInput; diff --git a/app-frontend/react/src/components/DropDown/DropDown.module.scss b/app-frontend/react/src/components/DropDown/DropDown.module.scss new file mode 100644 index 0000000..a8f0561 --- /dev/null +++ b/app-frontend/react/src/components/DropDown/DropDown.module.scss @@ -0,0 +1,63 @@ +.dropDown { + .noWrap { + white-space: nowrap; + display: flex; + + &.ellipsis span { + white-space: nowrap; + display: inline-block; + width: 150px; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + } + } + + .unsetMin { + min-width: unset; + } + + .chevron { + transform: rotate(0deg); + transition: transform 0.5s; + + &.open { + transform: rotate(180deg); + } + } + + &.border { + border-radius: 8px; + margin-left: 0.5rem; + + :global { + .MuiList-padding { + margin-left: 0 !important; + } + + .MuiListItemIcon-root { + min-width: unset; + } + } + + :global { + .MuiListItemText-root { + margin-top: 3px; + margin-bottom: 3px; + } + + .MuiList-root { + padding: 0; + margin-left: 0.5rem; + + .MuiButtonBase-root { + padding: 0 0.5rem; + } + } + } + } +} + +.leftGap { + margin-left: 0.5rem !important; +} diff --git a/app-frontend/react/src/components/DropDown/DropDown.tsx b/app-frontend/react/src/components/DropDown/DropDown.tsx new file mode 100644 index 0000000..dc839f9 --- /dev/null +++ b/app-frontend/react/src/components/DropDown/DropDown.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { + List, + ListItemButton, + ListItemText, + MenuItem, + Menu, + Typography, + ListItemIcon, + styled, + Box, +} from "@mui/material"; +import styles from "./DropDown.module.scss"; + +interface DropDownProps { + options: { name: string; value: string }[]; + value?: string; + handleChange: (value: string) => void; + readOnly?: boolean; + border?: boolean; + ellipsis?: true; +} + +const CustomMenuItem = styled(MenuItem)(({ theme }) => ({ + ...theme.customStyles.dropDown, +})); + +const DropDownWrapper = styled(Box)(({ theme }) => ({ + ...theme.customStyles.dropDown.wrapper, +})); + +const DropDown: React.FC = ({ + options, + value, + handleChange, + readOnly, + border, + ellipsis, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + + const foundIndex = options.findIndex((option) => option.value === value); + + const [selectedIndex, setSelectedIndex] = useState( + foundIndex !== -1 ? foundIndex : 0, + ); + + const open = Boolean(anchorEl); + const handleClickListItem = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuItemClick = (index: number) => { + setSelectedIndex(index); + setAnchorEl(null); + handleChange(options[index].value); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + if (readOnly) { + let name = foundIndex === -1 ? "Unknown" : options[selectedIndex].name; + return {name}; + } + + const Wrapper = border ? DropDownWrapper : Box; + + return options.length === 0 ? ( + <> + ) : ( + + + + + + + + + + + + {options.map((option, index) => ( + handleMenuItemClick(index)} + > + {option.name} + + ))} + + + ); +}; + +export default DropDown; diff --git a/app-frontend/react/src/components/File_Display/FileDisplay.module.scss b/app-frontend/react/src/components/File_Display/FileDisplay.module.scss new file mode 100644 index 0000000..46cb667 --- /dev/null +++ b/app-frontend/react/src/components/File_Display/FileDisplay.module.scss @@ -0,0 +1,44 @@ +.file { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + padding: 5px 10px; + border-radius: 5px; + margin-right: 0.5rem; + margin-bottom: 0.5rem; + + button { + margin-left: 0.5rem; + } + + .iconWrap { + border: none; + border-radius: 6px; + margin-right: 0.5rem; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + } + + .fileName { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; // Limits to 2 lines + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + max-width: 200px; + width: 100%; + font-size: 12px; + font-weight: 500; + } + + .fileExt { + font-size: 11px; + text-align: left; + margin-top: -2px; + } +} diff --git a/app-frontend/react/src/components/File_Display/FileDisplay.tsx b/app-frontend/react/src/components/File_Display/FileDisplay.tsx new file mode 100644 index 0000000..7aaed02 --- /dev/null +++ b/app-frontend/react/src/components/File_Display/FileDisplay.tsx @@ -0,0 +1,51 @@ +import { IconButton } from "@mui/material"; +import { Close, TaskOutlined, Language } from "@mui/icons-material"; +import styled from "styled-components"; +import styles from "./FileDisplay.module.scss"; + +const FileWrap = styled("div")(({ theme }) => ({ + ...theme.customStyles.fileInput.file, + ...theme.customStyles.gradientShadow, +})); + +const IconWrap = styled("div")(({ theme }) => ({ + ...theme.customStyles.sources.iconWrap, +})); + +interface FileProps { + file: File; + index: number; + remove?: (value: number) => void; + isWeb?: boolean; +} + +const FileDispaly: React.FC = ({ file, index, remove, isWeb }) => { + if (!file) return; + + let fileExtension = file.name.split(".").pop()?.toLowerCase(); + let fileName = isWeb ? file.name : file.name.split(".").shift(); + + return ( + + + + + +
+
+ {fileName} +
+ {!isWeb &&
.{fileExtension}
} +
+ + {remove && ( + remove(index)}> + + + )} + {isWeb && } +
+ ); +}; + +export default FileDispaly; diff --git a/app-frontend/react/src/components/File_Input/FileInput.module.scss b/app-frontend/react/src/components/File_Input/FileInput.module.scss new file mode 100644 index 0000000..273afe7 --- /dev/null +++ b/app-frontend/react/src/components/File_Input/FileInput.module.scss @@ -0,0 +1,69 @@ +.fileInputWrapper { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + .upload { + margin-left: 0.5rem; + } + + .inputWrapper { + padding: 1rem; + text-align: center; + box-shadow: none; + border-radius: 8px; + width: 100%; + position: relative; + } + + .expand { + width: 25px; + height: 25px; + border-radius: 25px; + min-width: unset; + border-width: 1px; + border-style: solid; + transition: transform 0.5s; + transform: rotate(0deg); + transform-origin: center; + margin-left: -12.5px; + margin-top: -20px; + position: absolute; + bottom: -12.5px; + z-index: 8; + + &.open { + transform: rotate(180deg); + } + } +} + +.previewFiles { + margin-bottom: 0.5rem; + + .fileList { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + } + + label { + margin-top: 0.5rem; + } +} + +.details { + max-height: 0px; + transition: max-height 0.4s; + overflow: hidden; + + &.detailsOpen { + max-height: 400px; + } +} + +.detailGap { + margin-top: 10px; +} diff --git a/app-frontend/react/src/components/File_Input/FileInput.tsx b/app-frontend/react/src/components/File_Input/FileInput.tsx new file mode 100644 index 0000000..a6213f0 --- /dev/null +++ b/app-frontend/react/src/components/File_Input/FileInput.tsx @@ -0,0 +1,393 @@ +import React, { useEffect, useReducer, useRef, useState } from "react"; +import { + Box, + Button, + Typography, + Paper, + IconButton, + styled, +} from "@mui/material"; +import { + UploadFile, + Close, + ExpandMore, + FileUploadOutlined, +} from "@mui/icons-material"; +import styles from "./FileInput.module.scss"; +import { + NotificationSeverity, + notify, +} from "@components/Notification/Notification"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + conversationSelector, + setSourceFiles, + setUploadInProgress, + uploadFile, +} from "@redux/Conversation/ConversationSlice"; +import ModalBox from "@shared/ModalBox/ModalBox"; +import { OutlineButton, SolidButton } from "@shared/ActionButtons"; +import { Link } from "react-router-dom"; +import FileDispaly from "@components/File_Display/FileDisplay"; +import ProgressIcon from "@components/ProgressIcon/ProgressIcon"; +import { s } from "vite/dist/node/types.d-aGj9QkWt"; + +const ExpandButton = styled(Button)(({ theme }) => ({ + ...theme.customStyles.promptExpandButton, +})); + +interface FileWithPreview { + file: File; + preview: string; +} + +interface FileInputProps { + imageInput?: boolean; + summaryInput?: boolean; + maxFileCount?: number; + confirmationModal?: boolean; + dataManagement?: boolean; +} + +const summaryFileExtensions = [ + "txt", + "pdf", + "docx", + "mp3", + "wav", + "ogg", + "mp4", + "avi", + "mov" +] + +const imageExtensions = ["jpg", "jpeg", "png", "gif"]; +const docExtensions = ["txt"]; +const dataExtensions = [ + "txt", + "pdf", + "csv", + "xls", + "xlsx", + "json" /*"doc", "docx", "md", "ppt", "pptx", "html", "xml", "xsl", "xslt", "rtf", "v", "sv"*/, +]; +const maxImageSize = 3 * 1024 * 1024; // 3MB +const maxDocSize = 80 * 1024 * 1024; // 200MB +const maxSummarySize = 80 * 1024 * 1024; // 200MB + +const FileInputWrapper = styled(Paper)(({ theme }) => ({ + ...theme.customStyles.fileInput.wrapper, +})); + +const FileInput: React.FC = ({ + maxFileCount = 5, + imageInput, + summaryInput, + dataManagement, +}) => { + const { model, models, useCase, filesInDataSource, uploadInProgress, type } = + useAppSelector(conversationSelector); + // const { filesInDataManagement, uploadInProgress } = useAppSelector(dataManagementSelector); + + const dispatch = useAppDispatch(); + const [confirmUpload, setConfirmUpload] = useState(false); + const [filesToUpload, setFilesToUpload] = useState< + (FileWithPreview | File)[] + >([]); + const [details, showDetails] = useState(filesToUpload.length === 0); + + const inputRef = useRef(null); + + const extensions = summaryInput? + summaryFileExtensions : + imageInput + ? imageExtensions + : dataManagement + ? dataExtensions + : docExtensions; + const maxSize = summaryInput? maxSummarySize: + imageInput ? maxImageSize : maxDocSize; + + const [insightToken, setInsightToken] = useState(0); + + useEffect(() => { + showDetails(filesToUpload.length === 0); + + // summary / faq + if (!dataManagement && filesToUpload.length > 0) { + dispatch(setSourceFiles(filesToUpload)); + } + }, [filesToUpload]); + + useEffect(() => { + // model sets insight token in summary/faq + if (!dataManagement) { + let selectedModel = models.find( + (thisModel) => thisModel.model_name === model, + ); + if (selectedModel) setInsightToken(selectedModel.maxToken); + } + }, [model, models]); + + useEffect(() => { + setFilesToUpload([]); + dispatch(setSourceFiles([])); + }, [type]); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const droppedFiles = Array.from(e.dataTransfer.files); + validateFiles(droppedFiles); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + const selectedFiles = Array.from(e.target.files); + const validated = validateFiles(selectedFiles); + if (validated) e.target.value = ""; // Clear input + } + }; + + const validateFiles = (newFiles: File[]) => { + if (newFiles.length + filesToUpload.length > maxFileCount) { + notify( + `You can only upload a maximum of ${maxFileCount} file${maxFileCount > 1 ? "s" : ""}.`, + NotificationSeverity.ERROR, + ); + return; + } + + const validFiles = newFiles.filter((file) => { + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const isSupportedExtension = extensions.includes(fileExtension || ""); + const isWithinSizeLimit = file.size <= maxSize; + + const compareTo = dataManagement ? filesInDataSource : filesToUpload; + + let duplicate = compareTo.some((f: any) => { + return f.name === file.name; + }); + + // duplicate file check, currently data management only (summary/faq single file) + if (duplicate) { + notify( + `File "${file.name}" is already added.`, + NotificationSeverity.ERROR, + ); + return false; + } + + if (!isSupportedExtension) { + notify( + `File "${file.name}" has an unsupported file type.`, + NotificationSeverity.ERROR, + ); + return false; + } + + if (!isWithinSizeLimit) { + notify( + `File "${file.name}" exceeds the maximum size limit of ${imageInput ? "3MB" : "200MB"}.`, + NotificationSeverity.ERROR, + ); + return false; + } + + return isSupportedExtension && isWithinSizeLimit; + }); + + if (validFiles.length > 0) { + addToQueue(validFiles); + } + + return true; + }; + + const addToQueue = async (newFiles: File[]) => { + const filteredFiles = newFiles.filter((file: File | FileWithPreview) => { + let activeFile = "file" in file ? file.file : file; + return !filesToUpload.some((f: File | FileWithPreview) => { + let comparedFile = "file" in f ? f.file : f; + return comparedFile.name === activeFile.name; + }); + }); + + const filesWithPreview = filteredFiles.map((file) => ({ + file, + preview: URL.createObjectURL(file), + })); + + setFilesToUpload([...filesToUpload, ...filesWithPreview]); + }; + + const removeFile = (index: number) => { + let updatedFiles = filesToUpload.filter( + (file, fileIndex) => index !== fileIndex, + ); + setFilesToUpload(updatedFiles); + }; + + const uploadFiles = async () => { + dispatch(setUploadInProgress(true)); + + const responses = await Promise.all( + filesToUpload.map((file: any) => { + dispatch(uploadFile({ file: file.file })); + }), + ); + + dispatch(setUploadInProgress(false)); + + setConfirmUpload(false); + setFilesToUpload([]); + }; + + const showConfirmUpload = () => { + setConfirmUpload(true); + }; + + const filePreview = () => { + if (filesToUpload.length > 0) { + return ( + + + {filesToUpload.map((file, fileIndex) => { + let activeFile = "file" in file ? file.file : file; + return ( + + + + ); + })} + + + ); + } else { + return ( + + Upload or Drop Files Here + + ); + } + }; + + const renderConfirmUpload = () => { + if (confirmUpload) { + return ( + + + Uploading files + setConfirmUpload(false)}> + + + + +

+ I hereby certify that the content uploaded is free from any + personally identifiable information or other private data that + would violate applicable privacy laws and regulations. +

+
+ uploadFiles()}> + Agree and Continue + + setConfirmUpload(false)}> + Cancel + +
+
+
+ ); + } + }; + + if (uploadInProgress) { + return ( + + + + + + ); + } + + return ( + + e.preventDefault()} + className={styles.inputWrapper} + > + {filePreview()} + +
+ {filesToUpload.length !== maxFileCount && ( + inputRef.current?.click()}> + Browse Files + + + )} + + {dataManagement && ( + + Upload + + )} +
+ + {filesToUpload.length > 0 && ( + showDetails(!details)} + > + + + )} + +
+ + Limit {imageInput ? "3MB" : "80MB"} per file. + + + + Valid file formats are {extensions.join(", ").toUpperCase()}. + + + + You can select maximum of {maxFileCount} valid file + {maxFileCount > 1 ? "s" : ""}. + + + {!dataManagement && ( + + Max supported input tokens for {imageInput && "images"} data + insight is{" "} + {insightToken >= 1000 ? insightToken / 1000 + "K" : insightToken} + + )} +
+
+ + {renderConfirmUpload()} +
+ ); +}; + +export default FileInput; diff --git a/app-frontend/react/src/components/Header/Header.module.scss b/app-frontend/react/src/components/Header/Header.module.scss new file mode 100644 index 0000000..4287826 --- /dev/null +++ b/app-frontend/react/src/components/Header/Header.module.scss @@ -0,0 +1,160 @@ +.header { + height: var(--header-height); + backdrop-filter: blur(5px); + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + padding: var(--header-gutter); + position: relative; + z-index: 999; +} + +.logoContainer { + display: flex; + align-items: center; /* Vertically centers the company name with the logo */ + gap: 10px; /* Adjusts space between logo and company name */ +} + +.logoImg { + /* Ensure the logo has a defined size if needed */ + height: 40px; /* Example height, adjust as needed */ + width: auto; /* Maintain aspect ratio */ +} + +.companyName { + font-size: 1.2rem; /* Adjust font size as needed */ + /* Add any other styling for the company name */ +} + +.viewContext { + display: inline-flex; + max-width: 200px; + + &.titleWrap { + display: flex; + align-items: center; + justify-content: center; + + :global { + svg { + min-width: 30px; + } + } + } + + &.capitalize { + text-transform: capitalize; + } + + @media screen and (max-width: 900px) { + display: none; + + &.titleWrap { + display: none; + } + } +} + +.sideWrapper { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + z-index: 999; + width: 50px; + margin-right: calc(var(--header-gutter) * 2); + min-width: 0px; + max-width: var(--sidebar-width); + transition: + width 0.3s, + min-width 0.3s; + + .chatCopy { + opacity: 0; + max-width: 0; + transition: + opacity 0.3s, + max-width 0.3s; + font-size: 0.75rem; + margin-right: 0.5rem; + white-space: nowrap; + } + + .chatWrapper { + display: flex; + flex-direction: row; + align-items: center; + } + + &.sideWrapperOpen { + width: calc(var(--sidebar-width) - (var(--header-gutter) * 2)); + min-width: calc(var(--sidebar-width) - (var(--header-gutter) * 2)); + + .chatCopy { + max-width: 100px; // enough to show the text + opacity: 1; + } + } +} + +.rightSide { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.rightActions { + display: flex; + flex-direction: row; + align-items: center; +} + +.companyName { + font-weight: 600; + @media screen and (max-width: 899px) { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + } +} + +.desktopUser { + display: none; + @media screen and (min-width: 900px) { + display: inline-block; + } +} + +.newChat { + display: none; + @media screen and (min-width: 900px) { + display: inline-block; + } +} + +.accessDropDown { + :global { + .MuiList-root { + padding: 0; + + .MuiButtonBase-root { + padding: 0; + margin-left: -10px; + padding: 0 10px; + + .MuiListItemText-root { + margin: 0px; + } + + .MuiTypography-root { + font-size: 12px !important; + font-style: italic; + } + } + } + } +} diff --git a/app-frontend/react/src/components/Header/Header.tsx b/app-frontend/react/src/components/Header/Header.tsx new file mode 100644 index 0000000..278f2a1 --- /dev/null +++ b/app-frontend/react/src/components/Header/Header.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from "react"; +import { styled } from "@mui/material/styles"; +import { Link, useNavigate } from "react-router-dom"; +import config from "@root/config"; +import opeaLogo from "@assets/icons/opea-icon-color.svg" + +import styles from "./Header.module.scss"; +import { Box, IconButton, Tooltip, Typography } from "@mui/material"; +import { SideBar } from "@components/SideBar/SideBar"; +// import DropDown from "@components/DropDown/DropDown"; +// import ThemeToggle from "@components/Header_ThemeToggle/ThemeToggle"; +import ViewSidebarOutlinedIcon from "@mui/icons-material/ViewSidebarOutlined"; +// import Create from "@mui/icons-material/Create"; +import AddCommentIcon from '@mui/icons-material/AddComment'; +// import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined"; +// import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; +import ChatBubbleIcon from "@icons/ChatBubble"; + +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { userSelector } from "@redux/User/userSlice"; +import { + Message, + MessageRole, + // UseCase, +} from "@redux/Conversation/Conversation"; +import { + conversationSelector, + // setUseCase, +} from "@redux/Conversation/ConversationSlice"; +import DownloadChat from "@components/Header_DownloadChat/DownloadChat"; + +interface HeaderProps { + asideOpen: boolean; + setAsideOpen: (open: boolean) => void; + chatView?: boolean; + historyView?: boolean; + dataView?: boolean; +} + +// interface AvailableUseCase { +// name: string; +// value: string; +// } + +const HeaderWrapper = styled(Box)(({ theme }) => ({ + ...theme.customStyles.header, +})); + +const Header: React.FC = ({ + asideOpen, + setAsideOpen, + chatView, + historyView, + dataView, +}) => { + const { companyName } = config; + + const sideBarRef = useRef(null); + const toggleRef = useRef(null); + + const navigate = useNavigate(); + + // const dispatch = useAppDispatch(); + const { role, name } = useAppSelector(userSelector); + const { selectedConversationHistory, type } = + useAppSelector(conversationSelector); + + const [currentTopic, setCurrentTopic] = useState(""); + + useEffect(() => { + if ( + !selectedConversationHistory || + selectedConversationHistory.length === 0 + ) { + setCurrentTopic(""); + return; + } + const firstUserPrompt = selectedConversationHistory.find( + (message: Message) => message.role === MessageRole.User, + ); + if (firstUserPrompt) setCurrentTopic(firstUserPrompt.content); + }, [selectedConversationHistory]); + + // const handleChange = (value: string) => { + // dispatch(setUseCase(value)); + // }; + + const newChat = () => { + navigate("/"); + setAsideOpen(false); + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + sideBarRef.current && + toggleRef.current && + !sideBarRef.current.contains(event.target as Node) && + !toggleRef.current.contains(event.target as Node) + ) { + setAsideOpen(false); + } + }; + + useEffect(() => { + if (asideOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [asideOpen]); + + const userDetails = () => { + return ( + +
{name}
+
+ ); + }; + + const getTitle = () => { + if (historyView) + return ( + + +   Your Chat History + + ); + + if (dataView) + return ( + + Data Source Management + + ); + + if (chatView) { + if (type !== "chat" && !currentTopic) { + return ( + + {type} + + ); + } else { + return ( + + + +   {currentTopic} + + + ); + } + } + }; + + return ( + + + + setAsideOpen(!asideOpen)}> + + + + + + + + + + +
+ + opea logo + {companyName} + + +
+ + {getTitle()} + + + + {/* New Chat */} + + + + + + + {chatView && ( + <> + {/* + + */} + + + + )} + + {/* {chatView && { }}>} */} + + {/* + + */} + + {/* {userDetails()} */} + +
+
+ ); +}; + +export default Header; diff --git a/app-frontend/react/src/components/Header_DownloadChat/DownloadChat.tsx b/app-frontend/react/src/components/Header_DownloadChat/DownloadChat.tsx new file mode 100644 index 0000000..0ed8a8c --- /dev/null +++ b/app-frontend/react/src/components/Header_DownloadChat/DownloadChat.tsx @@ -0,0 +1,74 @@ +import { FileDownloadOutlined } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; +import { conversationSelector } from "@redux/Conversation/ConversationSlice"; +import { useAppSelector } from "@redux/store"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +const DownloadChat = () => { + const { selectedConversationHistory, type, model, token, temperature } = + useAppSelector(conversationSelector); + const [url, setUrl] = useState(undefined); + const [fileName, setFileName] = useState(""); + + const safeBtoa = (str: string) => { + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(str); + let binaryString = ""; + for (let i = 0; i < uint8Array.length; i++) { + binaryString += String.fromCharCode(uint8Array[i]); + } + return btoa(binaryString); + }; + + useEffect(() => { + if (selectedConversationHistory.length === 0) return; + + //TODO: if we end up with a systemPrompt for code change this + const userPromptIndex = type === "code" ? 0 : 1; + + const conversationObject = { + model, + token, + temperature, + messages: [...selectedConversationHistory], + type, + }; + + const newUrl = `data:application/json;charset=utf-8;base64,${safeBtoa(JSON.stringify(conversationObject))}`; + + if ( + selectedConversationHistory && + selectedConversationHistory.length > 0 && + selectedConversationHistory[userPromptIndex] + ) { + const firstPrompt = selectedConversationHistory[userPromptIndex].content; // Assuming content is a string + if (firstPrompt) { + const newFileName = firstPrompt.split(" ").slice(0, 4).join("_"); + setUrl(newUrl); + setFileName(newFileName.toLowerCase()); + } + } + }, [selectedConversationHistory]); + + //TODO: only support download for chat for now + return ( + url && + type === "chat" && ( + + + + + + + + ) + ); +}; + +export default DownloadChat; diff --git a/app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.module.scss b/app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.module.scss new file mode 100644 index 0000000..1d69292 --- /dev/null +++ b/app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.module.scss @@ -0,0 +1,65 @@ +.toggleWrapper { + position: relative; + margin-right: 10px; + display: flex; + align-items: center; + + .toggle { + width: 100px; + height: 34px; + padding: 7px; + } + + .copy { + position: absolute; + z-index: 99; + margin: 0 26px; + font-size: 14px; + } + + :global { + .MuiSwitch-switchBase { + margin: 1px; + padding: 0; + transform: translateX(6px); + transition: transform 0.3s; + + &.Mui-checked { + color: #fff; + transform: translateX(62px); + + .MuiSwitch-track { + opacity: 1; + } + } + } + + .MuiSwitch-track { + opacity: 1; + height: 30px; + border-radius: 30px; + margin-top: -5px; + background-color: transparent !important; + } + + .MuiSwitch-thumb { + // background-color: transparent !important; + width: 26px; + height: 26px; + position: relative; + margin-top: 3px; + margin-left: 2px; + box-shadow: none; + &::before { + content: ""; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + background-repeat: no-repeat; + background-position: center; + } + } + } +} diff --git a/app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.tsx b/app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.tsx new file mode 100644 index 0000000..6998770 --- /dev/null +++ b/app-frontend/react/src/components/Header_ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,48 @@ +import React, { useContext } from "react"; +import { styled } from "@mui/material/styles"; +import { Switch, Typography, Box } from "@mui/material"; +import { ThemeContext } from "@contexts/ThemeContext"; +import styles from "./ThemeToggle.module.scss"; + +const MaterialUISwitch = styled(Switch)(({ theme }) => ({ + ...theme.customStyles.themeToggle, +})); + +const ThemeToggle: React.FC = () => { + const { darkMode, toggleTheme } = useContext(ThemeContext); + const [checked, setChecked] = React.useState(darkMode); + + const handleChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + toggleTheme(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleChange({ + target: { checked: !checked }, + } as React.ChangeEvent); + } + }; + + return ( + + + {checked ? "Dark" : "Light"} + + + + ); +}; + +export default ThemeToggle; diff --git a/app-frontend/react/src/components/Message/conversationMessage.module.scss b/app-frontend/react/src/components/Message/conversationMessage.module.scss deleted file mode 100644 index b006495..0000000 --- a/app-frontend/react/src/components/Message/conversationMessage.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -@import "../../styles/styles"; - -.conversationMessage { - @include flex(column, nowrap, flex-start, flex-start); - margin-top: 16px; - padding: 0 32px; - width: 100%; - - & > * { - width: 100%; - } -} diff --git a/app-frontend/react/src/components/Message/conversationMessage.tsx b/app-frontend/react/src/components/Message/conversationMessage.tsx deleted file mode 100644 index 66df29d..0000000 --- a/app-frontend/react/src/components/Message/conversationMessage.tsx +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import { IconAi, IconUser } from "@tabler/icons-react"; -import style from "./conversationMessage.module.scss"; -import { Badge, Card, Loader, Text, Tooltip, Button, Collapse, Flex } from "@mantine/core"; -import { DateTime } from "luxon"; -import { useState } from 'react'; -import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; -import { AgentStep, isAgentSelector } from '../../redux/Conversation/ConversationSlice'; -import { useAppSelector } from '../../redux/store'; - - -export interface ConversationMessageProps { - message: string; - human: boolean; - date: number; - tokenCount?: number; - tokenRate?: number; - elapsedTime?: number; - agentSteps: AgentStep[]; - // isInThink: boolean; -} - -export function ConversationMessage({ human, message, date, elapsedTime, tokenCount, tokenRate, agentSteps }: ConversationMessageProps) { - const dateFormat = () => { - return DateTime.fromJSDate(new Date(date)).toLocaleString(DateTime.DATETIME_MED); - }; - - const [showThoughts, setShowThoughts] = useState(true); - const isAgent = useAppSelector(isAgentSelector); - - return ( -
- - {human ? : } - -
- - {human ? "You" : "Assistant"} - - - {dateFormat()} - -
-
- - {!human && isAgent && ( -
- - - {agentSteps.length > 0 ? ( - agentSteps.map((step, index) => ( - - - - Step {index + 1} - - {step.tool && ( - - Tool: {step.tool} - - )} - - {step.content.length > 0 && ( - - {step.content.join(", ")} - - )} - {step.source.length > 0 && ( - - {step.source.join(", ")} - - )} - - )) - ) : ( - - - Thinking... - - - )} - -
- )} - - - {human ? message : message === "..." ? : message} - - - {!human && elapsedTime !== undefined && tokenCount !== undefined && tokenRate !== undefined && ( - - - Time: {elapsedTime.toFixed(2)}s • Tokens: {tokenCount} • {tokenRate.toFixed(2)} tokens/s - - - )} -
- ); -} \ No newline at end of file diff --git a/app-frontend/react/src/components/Notification/Notification.tsx b/app-frontend/react/src/components/Notification/Notification.tsx new file mode 100644 index 0000000..f3948c5 --- /dev/null +++ b/app-frontend/react/src/components/Notification/Notification.tsx @@ -0,0 +1,144 @@ +import { AlertColor, IconButton, styled } from "@mui/material"; +import { + SnackbarProvider, + useSnackbar, + MaterialDesignContent, + closeSnackbar, +} from "notistack"; +import { useEffect } from "react"; +import { Subject } from "rxjs"; +import { + TaskAlt, + WarningAmberOutlined, + ErrorOutlineOutlined, + InfoOutlined, + Close, +} from "@mui/icons-material"; + +interface NotificationDataProps { + message: string; + variant: AlertColor; +} + +type NotificationSeverity = "error" | "info" | "success" | "warning"; + +export const NotificationSeverity = { + SUCCESS: "success" as NotificationSeverity, + ERROR: "error" as NotificationSeverity, + WARNING: "warning" as NotificationSeverity, + INFO: "info" as NotificationSeverity, +}; + +const severityColor = (variant: string) => { + switch (variant) { + case "success": + return "#388e3c"; + case "error": + return "#d32f2f"; + case "warning": + return "#f57c00"; + case "info": + return "#0288d1"; + default: + return "rgba(0, 0, 0, 0.87)"; + } +}; + +const StyledMaterialDesignContent = styled(MaterialDesignContent)<{ + severity: AlertColor; +}>(({ variant }) => ({ + backgroundColor: (() => { + switch (variant) { + case "success": + return "rgb(225,238,226)"; + case "error": + return "rgb(248,224,224)"; + case "warning": + return "rgb(254,235,217)"; + case "info": + return "rgb(217,237,248)"; + default: + return "rgb(225,238,226)"; + } + })(), + border: `1px solid ${severityColor(variant)}`, + color: severityColor(variant), + ".MuiAlert-action": { + paddingTop: 0, + scale: 0.8, + borderLeft: `1px solid ${severityColor(variant)}`, + marginLeft: "1rem", + }, + svg: { + marginRight: "1rem", + }, + "button svg": { + marginRight: "0", + path: { + fill: severityColor(variant), + }, + }, +})); + +const CloseIcon = styled(IconButton)(() => ({ + minWidth: "unset", +})); + +const Notify = new Subject(); + +export const notify = (message: string, variant: AlertColor) => { + if (!variant) variant = NotificationSeverity.SUCCESS; + Notify.next({ message, variant }); +}; + +const NotificationComponent = () => { + const { enqueueSnackbar } = useSnackbar(); + + useEffect(() => { + const subscription = Notify.subscribe({ + next: (notification) => { + enqueueSnackbar(notification.message, { + variant: notification.variant, + action: (key) => ( + closeSnackbar(key)} + variant={notification.variant} + > + + + ), + }); + }, + }); + + return () => subscription.unsubscribe(); + }, []); + + return <>; +}; + +const Notification = () => { + return ( + , + warning: , + error: , + info: , + }} + Components={{ + success: StyledMaterialDesignContent, + warning: StyledMaterialDesignContent, + error: StyledMaterialDesignContent, + info: StyledMaterialDesignContent, + }} + > + + + ); +}; + +export default Notification; diff --git a/app-frontend/react/src/components/PrimaryInput/PrimaryInput.module.scss b/app-frontend/react/src/components/PrimaryInput/PrimaryInput.module.scss new file mode 100644 index 0000000..184c4d7 --- /dev/null +++ b/app-frontend/react/src/components/PrimaryInput/PrimaryInput.module.scss @@ -0,0 +1,44 @@ +.inputWrapper { + position: relative; +} + +.primaryInput { + border-radius: var(--input-radius); + overflow: hidden; + position: relative; + display: flex; + + .inputActions { + display: flex; + flex-direction: row; + align-items: center; + position: absolute; + right: 10px; + bottom: 10px; + } + + .circleButton { + border-radius: 40px; + width: 40px; + height: 40px; + min-width: 40px; + margin-left: 10px; + } + + .textAreaAuto { + font-family: "Inter", serif; + padding: var(--header-gutter) 100px var(--header-gutter) var(--header-gutter); + border: 0; + width: 100%; + resize: none; + background-color: transparent; + + &:focus { + outline: none; + } + + &.summaryInput { + padding: var(--header-gutter) 70px var(--header-gutter) var(--header-gutter); + } + } +} diff --git a/app-frontend/react/src/components/PrimaryInput/PrimaryInput.tsx b/app-frontend/react/src/components/PrimaryInput/PrimaryInput.tsx new file mode 100644 index 0000000..4f7e5ef --- /dev/null +++ b/app-frontend/react/src/components/PrimaryInput/PrimaryInput.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from "react"; +import { Box, Button, styled, TextareaAutosize } from "@mui/material"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import styles from "./PrimaryInput.module.scss"; +import { Stop } from "@mui/icons-material"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + abortStream, + conversationSelector, + // saveConversationtoDatabase, + setSourceFiles, + setSourceLinks, +} from "@redux/Conversation/ConversationSlice"; +import AudioInput from "@components/PrimaryInput_AudioInput/AudioInput"; +import PromptSelector from "@components/PrimparyInput_PromptSelector/PromptSelector"; +import { + NotificationSeverity, + notify, +} from "@components/Notification/Notification"; + +const InputWrapper = styled(Box)(({ theme }) => ({ + ...theme.customStyles.primaryInput.inputWrapper, +})); + +const TextInput = styled(TextareaAutosize)(({ theme }) => ({ + ...theme.customStyles.primaryInput.textInput, +})); + +const CircleButton = styled(Button)(({ theme }) => ({ + ...theme.customStyles.primaryInput.circleButton, +})); + +interface PrimaryInputProps { + onSend: (messageContent: string) => Promise; + type?: string; + home?: boolean; +} + +const PrimaryInput: React.FC = ({ + onSend, + home = false, +}) => { + const { + onGoingResult, + type, + selectedConversationId, + sourceLinks, + sourceFiles, + } = useAppSelector(conversationSelector); + const dispatch = useAppDispatch(); + + const [promptText, setPromptText] = useState(""); + const clearText = useRef(true); + + const isSummary = type === "summary"; + const isFaq = type === "faq"; + + useEffect(() => { + if (clearText.current) setPromptText(""); + clearText.current = true; + }, [type, sourceFiles, sourceLinks]); + + const handleSubmit = () => { + if ( + (isSummary || isFaq) && + sourceLinks && + sourceLinks.length === 0 && + sourceFiles && + sourceFiles.length === 0 && + promptText === "" + ) { + notify("Please provide content process", NotificationSeverity.ERROR); + return; + } else if (!(isSummary || isFaq) && promptText === "") { + notify("Please provide a message", NotificationSeverity.ERROR); + return; + } + + let textToSend = promptText; + onSend(textToSend); + setPromptText(""); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.shiftKey && event.key === "Enter") { + handleSubmit(); + } + }; + + const updatePromptText = (value: string) => { + setPromptText(value); + if (sourceFiles.length > 0) { + clearText.current = false; + dispatch(setSourceFiles([])); + } + if (sourceLinks.length > 0) { + clearText.current = false; + dispatch(setSourceLinks([])); + } + }; + + // const cancelStream = () => { + // dispatch(abortStream()); + // if (type === "chat") { + // dispatch( + // saveConversationtoDatabase({ + // conversation: { id: selectedConversationId }, + // }), + // ); + // } + // }; + + const isActive = () => { + if ((isSummary || isFaq) && sourceFiles.length > 0) { + return true; + } else if (promptText !== "") return true; + return false; + }; + + const submitButton = () => { + if (!onGoingResult) { + return ( + + + + ); + } + return; + }; + + const placeHolderCopy = () => { + if (home && (isSummary || isFaq)) return "Enter text here or sources below"; + else return "Enter your message"; + }; + + const renderInput = () => { + if (!home && onGoingResult && (isSummary || isFaq)) { + return ( + + + + + + ); + } else if ((!home && !isSummary && !isFaq) || home) { + return ( + + + ) => + updatePromptText(e.target.value) + } + onKeyDown={handleKeyDown} + sx={{ + resize: "none", + backgroundColor: "transparent", + }} + /> + + + {/* */} + + {onGoingResult && ( + + + + )} + + {submitButton()} + + + + {/* {home && !isSummary && !isFaq && ( + + )} */} + + ); + } + }; + + return renderInput(); +}; + +export default PrimaryInput; diff --git a/app-frontend/react/src/components/PrimaryInput_AudioInput/AudioInput.tsx b/app-frontend/react/src/components/PrimaryInput_AudioInput/AudioInput.tsx new file mode 100644 index 0000000..d304ebd --- /dev/null +++ b/app-frontend/react/src/components/PrimaryInput_AudioInput/AudioInput.tsx @@ -0,0 +1,85 @@ +import { Mic } from "@mui/icons-material"; +import { Button, styled, Tooltip } from "@mui/material"; +import { useState } from "react"; +import styles from "@components/PrimaryInput/PrimaryInput.module.scss"; +import { + NotificationSeverity, + notify, +} from "@components/Notification/Notification"; +import { useAppSelector } from "@redux/store"; +import { conversationSelector } from "@redux/Conversation/ConversationSlice"; +import ProgressIcon from "@components/ProgressIcon/ProgressIcon"; + +interface AudioInputProps { + setSearchText: (value: string) => void; +} + +const AudioButton = styled(Button)(({ theme }) => ({ + ...theme.customStyles.audioEditButton, +})); + +const AudioInput: React.FC = ({ setSearchText }) => { + const isSpeechRecognitionSupported = + ("webkitSpeechRecognition" in window || "SpeechRecognition" in window) && + window.isSecureContext; + + const { type } = useAppSelector(conversationSelector); + const [isListening, setIsListening] = useState(false); + + const handleMicClick = () => { + const SpeechRecognition = + (window as any).SpeechRecognition || + (window as any).webkitSpeechRecognition; + const recognition = new SpeechRecognition(); + recognition.lang = "en-US"; // Set language for recognition + recognition.interimResults = false; // Only process final results + + if (!isListening) { + setIsListening(true); + recognition.start(); + + recognition.onresult = (event: SpeechRecognitionEvent) => { + const transcript = event.results[0][0].transcript; + setSearchText(transcript); // Update search text with recognized speech + setIsListening(false); + }; + + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + notify( + `Speech recognition error:${event.error}`, + NotificationSeverity.ERROR, + ); + console.error("Speech recognition error:", event); + setIsListening(false); + }; + + recognition.onend = () => { + setIsListening(false); + }; + } else { + recognition.stop(); + setIsListening(false); + } + }; + + const renderMic = () => { + if (type === "summary" || type === "faq" || !isSpeechRecognitionSupported) + return <>; + + if (isListening) { + return ; + } else { + return ( + + + + + + ); + } + }; + + return renderMic(); +}; + +export default AudioInput; diff --git a/app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.module.scss b/app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.module.scss new file mode 100644 index 0000000..e90edb4 --- /dev/null +++ b/app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.module.scss @@ -0,0 +1,87 @@ +.promptsWrapper { + position: absolute; + + z-index: 99; + width: 100%; + padding: 0 40px; + + .expand { + width: 25px; + height: 25px; + border-radius: 25px; + min-width: unset; + border-width: 1px; + border-style: solid; + transform: rotate(180deg); + transition: transform 0.5s; + margin-top: -20px; + position: relative; + z-index: 9999; + + &.open { + transform: rotate(0deg); + } + } +} + +.promptText { + color: var(--copy-color) !important; +} + +.promptsListWrapper { + margin-top: -23px; + max-height: 0px; + transition: max-height 0.5s; + overflow: hidden; + // background: #fff; + width: 100%; + z-index: 999; + + &.open { + max-height: 250px; + overflow-y: auto; + } + + ul { + padding: 0; + } + + li { + border-bottom: 1px solid; + padding: 0; + justify-content: space-between; + + button { + padding: 1rem; + width: 100%; + justify-content: flex-start; + text-align: left; + border-radius: 0px; + box-shadow: none; + } + + &:first-of-type button { + padding-top: 1.2rem; + + span { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; // Limits to 2 lines + overflow: hidden; + text-overflow: ellipsis; + } + } + + .delete { + width: 40px; + height: 40px; + border-radius: 40px; + margin: 0 0.5rem; + justify-content: center; + + path { + fill: #cc0000; + } + } + } +} diff --git a/app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.tsx b/app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.tsx new file mode 100644 index 0000000..00d58c5 --- /dev/null +++ b/app-frontend/react/src/components/PrimparyInput_PromptSelector/PromptSelector.tsx @@ -0,0 +1,113 @@ +import { + Box, + Button, + IconButton, + List, + ListItem, + styled, + Tooltip, +} from "@mui/material"; +import { deletePrompt, promptSelector } from "@redux/Prompt/PromptSlice"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { useEffect, useRef, useState } from "react"; +import { Delete, ExpandMore } from "@mui/icons-material"; +import styles from "./PromptSelector.module.scss"; + +const ExpandButton = styled(Button)(({ theme }) => ({ + ...theme.customStyles.promptExpandButton, +})); + +const PromptButton = styled(Button)(({ theme }) => ({ + ...theme.customStyles.promptButton, +})); + +const PromptListWrapper = styled(Box)(({ theme }) => ({ + ...theme.customStyles.promptListWrapper, +})); + +interface PromptSelectorProps { + setSearchText: (value: string) => void; +} + +const PromptSelector: React.FC = ({ setSearchText }) => { + const dispatch = useAppDispatch(); + const { prompts } = useAppSelector(promptSelector); + const [showPrompts, setShowPrompts] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + if (!showPrompts) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + wrapperRef.current && + !wrapperRef.current.contains(event.target as Node) + ) { + setShowPrompts(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showPrompts]); + + const handleDelete = (id: string, text: string) => { + dispatch(deletePrompt({ promptId: id, promptText: text })); + }; + + const handleSelect = (promptText: string) => { + setSearchText(promptText); + setShowPrompts(false); + }; + + return ( + prompts && + prompts.length > 0 && ( + + + setShowPrompts(!showPrompts)} + > + + + + + + + {prompts?.map((prompt, promptIndex) => { + return ( + + handleSelect(prompt.prompt_text)} + > + {prompt.prompt_text} + + + + + handleDelete(prompt.id, prompt.prompt_text) + } + > + + + + + ); + })} + + + + ) + ); +}; + +export default PromptSelector; diff --git a/app-frontend/react/src/components/ProgressIcon/ProgressIcon.tsx b/app-frontend/react/src/components/ProgressIcon/ProgressIcon.tsx new file mode 100644 index 0000000..aa8d3ec --- /dev/null +++ b/app-frontend/react/src/components/ProgressIcon/ProgressIcon.tsx @@ -0,0 +1,13 @@ +import { CircularProgress, styled } from "@mui/material"; + +const ProgressIconStyle = styled(CircularProgress)(({ theme }) => ({ + "svg circle": { + stroke: theme.customStyles.audioProgress?.stroke, + }, +})); + +const ProgressIcon = () => { + return ; +}; + +export default ProgressIcon; diff --git a/app-frontend/react/src/components/PromptSettings/PromptSettings.module.scss b/app-frontend/react/src/components/PromptSettings/PromptSettings.module.scss new file mode 100644 index 0000000..6b64e28 --- /dev/null +++ b/app-frontend/react/src/components/PromptSettings/PromptSettings.module.scss @@ -0,0 +1,89 @@ +.promptSettingsWrapper { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + max-width: 775px; + + .summarySource { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + :global { + .MuiFormGroup-root { + margin-bottom: 0.75rem; + + label { + margin-left: 0; + } + + &:not(:last-of-type) { + margin-right: 0.75rem; + } + } + } +} + +.promptSettings { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-top: calc(var(--vertical-spacer) / 2); + flex-wrap: wrap; + width: 100%; + + @media screen and (max-width: 900px) { + padding: 0 var(--content-gutter); + } + + :global { + .MuiFormControlLabel-label, + .MuiTypography-root { + font-size: 13px; + font-weight: 400; + } + } + + &.readOnly { + flex-direction: column; + align-items: flex-start; + padding: 0; + margin-top: 0; + + :global { + .MuiFormGroup-root { + margin-bottom: 0; + margin-right: 0; + + label { + width: 100%; + align-items: flex-start; + margin: 0; + } + } + + @media screen and (max-width: 900px) { + .MuiFormGroup-root:not(:last-of-type) { + margin-bottom: 0; + } + } + } + } +} + +.systemPromptTextarea { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 13px; /* Matches .MuiFormControlLabel-label font-size */ + font-family: inherit; + background-color: #fff; + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + } +} diff --git a/app-frontend/react/src/components/PromptSettings/PromptSettings.tsx b/app-frontend/react/src/components/PromptSettings/PromptSettings.tsx new file mode 100644 index 0000000..50d4a2d --- /dev/null +++ b/app-frontend/react/src/components/PromptSettings/PromptSettings.tsx @@ -0,0 +1,268 @@ +import { useEffect, useState } from "react"; + +// import DropDown from "@components/DropDown/DropDown"; +import CustomSlider from "@components/PromptSettings_Slider/Slider"; +import { Box, Collapse, FormGroup, FormControlLabel, FormLabel, IconButton, TextareaAutosize } from "@mui/material"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import styles from "./PromptSettings.module.scss"; +import TokensInput from "@components/PromptSettings_Tokens/TokensInput"; +import FileInput from "@components/File_Input/FileInput"; +// import WebInput from "@components/Summary_WebInput/WebInput"; + +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { Model } from "@redux/Conversation/Conversation"; +import { + conversationSelector, + setModel, + setSourceType, + setTemperature, + setToken, + setSystemPrompt, +} from "@redux/Conversation/ConversationSlice"; + +interface AvailableModel { + name: string; + value: string; +} + +interface PromptSettingsProps { + readOnly?: boolean; +} + +const PromptSettings: React.FC = ({ + readOnly = false, +}) => { + const dispatch = useAppDispatch(); + + const { models, type, sourceType, model, token, maxToken, temperature, systemPrompt } = + useAppSelector(conversationSelector); + + const [tokenError, setTokenError] = useState(false); + const [isSystemPromptOpen, setIsSystemPromptOpen] = useState(false); + + const filterAvailableModels = (): AvailableModel[] => { + if (!models || !type) return []; + + let typeModels: AvailableModel[] = []; + + models.map((model: Model) => { + if (model.types.includes(type)) { + typeModels.push({ + name: model.displayName, + value: model.model_name, + }); + } + }); + + return typeModels; + }; + + const [formattedModels, setFormattedModels] = useState( + filterAvailableModels(), + ); + + useEffect(() => { + setFormattedModels(filterAvailableModels()); + }, [type, models]); + + useEffect(() => { + if (!model) return; + setTokenError(token > maxToken); + }, [model, token]); + + useEffect(() => { + // console.log("System Prompt Opened: ", isSystemPromptOpen); + }, [isSystemPromptOpen]); + + const updateTemperature = (value: number) => { + dispatch(setTemperature(Number(value))); + }; + + const updateTokens = (value: number) => { + dispatch(setToken(Number(value))); + }; + + const updateSystemPrompt = (value: string) => { + dispatch(setSystemPrompt(value)); + }; + + // const updateModel = (value: string) => { + // const selectedModel = models.find( + // (model: Model) => model.model_name === value, + // ); + // if (selectedModel) { + // dispatch(setModel(selectedModel)); + // } + // }; + + const updateSource = (value: string) => { + dispatch(setSourceType(value)); + }; + + const cursorDisable = () => { + return readOnly ? { pointerEvents: "none" } : {}; + }; + + const displaySummarySource = () => { + if ((type !== "summary" && type !== "faq") || readOnly) return; + + let input = null; + // if (sourceType === "documents") input = ; + // if (sourceType === "web") input = ; + // if (sourceType === "images" && type === "summary") + // input = ; + input = ; + + return
+ {input} +
; + }; + + // in the off chance specific types do not use these, + // they have been pulled into their own function + const tokenAndTemp = () => { + return ( + <> + + setIsSystemPromptOpen(!isSystemPromptOpen)} + sx={{ padding: '0.5rem' }} + disabled={readOnly} + > + {isSystemPromptOpen ? : } + + + Inference Settings + + + + + + + + + } + label={`Tokens${readOnly ? ": " : ""}`} + labelPlacement="start" + /> + + + + + } + label={`Temperature${readOnly ? ": " : ""}`} + labelPlacement="start" + /> + + + { + type === "chat" && + + updateSystemPrompt(e.target.value)} + disabled={readOnly} + className={styles.systemPromptTextarea} + placeholder="Enter system prompt here..." + style={{ width: '100%', resize: 'vertical' }} + /> + } + label="System Prompt" + labelPlacement="start" + /> + + } + + + ); + }; + + // const displaySettings = () => { + // if (type === "summary" || type === "faq") { + // //TODO: Supporting only documents to start + // return ( + // <> + // + // + // } + // label="Summary Source" + // labelPlacement="start" + // /> + // + // + // ); + // } else { + // return <>; // tokenAndTemp() // see line 113, conditional option + // } + // }; + + return ( + + + {/* {formattedModels && formattedModels.length > 0 && ( + + + } + label={`Model${readOnly ? ": " : ""}`} + labelPlacement="start" + /> + + )} */} + + {tokenAndTemp()} + + + {/* TODO: Expand source options and show label with dropdown after expansion */} + {/* {displaySettings()} */} + + {displaySummarySource()} + + ); +}; + +export default PromptSettings; diff --git a/app-frontend/react/src/components/PromptSettings_Slider/Slider.module.scss b/app-frontend/react/src/components/PromptSettings_Slider/Slider.module.scss new file mode 100644 index 0000000..bdb1245 --- /dev/null +++ b/app-frontend/react/src/components/PromptSettings_Slider/Slider.module.scss @@ -0,0 +1,88 @@ +.sliderWrapper { + flex-direction: row; + align-items: center; + width: 100%; + max-width: 320px; + + flex-wrap: nowrap !important; + + font-size: 13px; + font-weight: 400; + + .start { + margin-left: 0.5rem; + } + + .trackWrapper { + margin: 0 0.5rem; + width: 100px; + display: flex; + } + + .styledSlider { + height: 2px; + width: 100%; + padding: 16px 0; + display: inline-flex; + align-items: center; + position: relative; + cursor: pointer; + touch-action: none; + -webkit-tap-highlight-color: transparent; + flex-wrap: nowrap; + + &.disabled { + pointer-events: none; + cursor: default; + opacity: 0.4; + } + + :global { + .MuiSlider-rail { + display: block; + position: absolute; + width: 100%; + height: 2px; + border-radius: 2px; + opacity: 0.3; + } + + .MuiSlider-track { + display: block; + position: absolute; + height: 0px; + } + + .MuiSlider-thumb { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + margin-left: -2px; + width: 10px; + height: 10px; + box-sizing: border-box; + border-radius: 50%; + outline: 0; + transition-property: box-shadow, transform; + transition-timing-function: ease; + transition-duration: 120ms; + transform-origin: center; + + &:hover { + } + + &.focusVisible { + outline: none; + } + + &.active { + outline: none; + } + + &.disabled { + } + } + } + } +} diff --git a/app-frontend/react/src/components/PromptSettings_Slider/Slider.tsx b/app-frontend/react/src/components/PromptSettings_Slider/Slider.tsx new file mode 100644 index 0000000..93bbd24 --- /dev/null +++ b/app-frontend/react/src/components/PromptSettings_Slider/Slider.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { styled } from "@mui/material/styles"; +import { Slider, Grid2, Typography } from "@mui/material"; +import styles from "./Slider.module.scss"; + +const StyledSlider = styled(Slider)(({ theme }) => ({ + ...theme.customStyles.styledSlider, +})); + +interface CustomSliderProps { + value: number; + handleChange: (value: number) => void; + readOnly?: boolean; +} + +const CustomSlider: React.FC = ({ + value, + handleChange, + readOnly, +}) => { + if (readOnly) { + return {value}; + } + + const handleSlideUpdate = (event: Event, value: number) => { + handleChange(value); + }; + + return ( + + 0 + + + + 1 + + ); +}; + +export default CustomSlider; diff --git a/app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.module.scss b/app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.module.scss new file mode 100644 index 0000000..4d0f13d --- /dev/null +++ b/app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.module.scss @@ -0,0 +1,49 @@ +.numberInput { + font-weight: 400; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + + input { + font-size: 13px; + font-family: inherit; + font-weight: 400; + line-height: 1.375; + border-radius: 8px; + margin: 0 8px 0 0; + padding: 3px 5px; + outline: 0; + min-width: 0; + width: 3.5rem; + text-align: center; + background: transparent; + + &:hover { + } + + &:focus { + } + + &:focus-visible { + outline: 0; + } + + /* Chrome, Safari, Edge, Opera */ + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + &[type="number"] { + -moz-appearance: textfield; + } + } + + .error, + .error:focus { + border: 1px solid #cc0000; + } +} diff --git a/app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.tsx b/app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.tsx new file mode 100644 index 0000000..6b4bd21 --- /dev/null +++ b/app-frontend/react/src/components/PromptSettings_Tokens/TokensInput.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { styled } from "@mui/material/styles"; + +import { Typography } from "@mui/material"; +import styles from "./TokensInput.module.scss"; + +interface NumberInputProps { + value?: number; + handleChange: (value: number) => void; + error: boolean; + readOnly?: boolean; +} + +const StyledInput = styled("input")(({ theme }) => ({ + ...theme.customStyles.tokensInput, +})); + +const TokensInput: React.FC = ({ + value = 1, + handleChange, + error, + readOnly, +}) => { + if (readOnly) { + return {value}; + } + + return ( +
+ ) => + handleChange(parseInt(e.target.value, 10)) + } + aria-label="Quantity Input" + /> +
+ ); +}; + +export default TokensInput; diff --git a/app-frontend/react/src/components/SearchInput/SearchInput.module.scss b/app-frontend/react/src/components/SearchInput/SearchInput.module.scss new file mode 100644 index 0000000..33207e8 --- /dev/null +++ b/app-frontend/react/src/components/SearchInput/SearchInput.module.scss @@ -0,0 +1,17 @@ +.searchInput { + width: 100%; + margin-bottom: 1rem; + border-radius: var(--input-radius); + border: 0; + margin-bottom: 2rem; + + &:focus { + outline: none; + } + + :global { + .MuiInputBase-root { + border-radius: var(--input-radius); + } + } +} diff --git a/app-frontend/react/src/components/SearchInput/SearchInput.tsx b/app-frontend/react/src/components/SearchInput/SearchInput.tsx new file mode 100644 index 0000000..49a11f6 --- /dev/null +++ b/app-frontend/react/src/components/SearchInput/SearchInput.tsx @@ -0,0 +1,63 @@ +import { InputAdornment, styled, TextField } from "@mui/material"; +import styles from "./SearchInput.module.scss"; +import { Close, Search } from "@mui/icons-material"; +import { useRef, useState } from "react"; + +const StyledSearchInput = styled(TextField)(({ theme }) => ({ + ...theme.customStyles.webInput, +})); + +interface SearchInputProps { + handleSearch: (value: string) => void; +} + +const SearchInput: React.FC = ({ handleSearch }) => { + const [hasValue, setHasValue] = useState(false); + + const inputRef = useRef(null); + + const clearSearch = () => { + if (inputRef.current) { + const input = inputRef.current.querySelector("input"); + if (input) input.value = ""; + } + handleSearch(""); + setHasValue(false); + }; + + const search = (value: string) => { + handleSearch(value); + setHasValue(value !== ""); + }; + + return ( + ) => + search(e.target.value) + } + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: hasValue && ( + + + + ), + }} + fullWidth + /> + ); +}; + +export default SearchInput; diff --git a/app-frontend/react/src/components/SideBar/SideBar.module.scss b/app-frontend/react/src/components/SideBar/SideBar.module.scss new file mode 100644 index 0000000..67b98b7 --- /dev/null +++ b/app-frontend/react/src/components/SideBar/SideBar.module.scss @@ -0,0 +1,117 @@ +.aside { + max-width: var(--sidebar-width); + position: fixed; + width: 0; + overflow: hidden; + transition: width 0.3s; + height: 100vh; + top: 0; + left: 0; + z-index: 998; + padding-top: var(--header-height); + display: flex; + flex-direction: column; + justify-content: space-between; + + &.open { + max-width: var(--sidebar-width); + width: var(--sidebar-width); + } + + .asideContent { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + display: flex; + flex-direction: column; + list-style: none; + overflow: auto; + max-height: 100%; + margin-bottom: auto; + + .emptySvg { + width: 24px; + height: 24px; + min-width: 24px; + } + + li, + a { + display: flex; + flex-direction: row; + align-items: center; + } + + li { + padding-left: var(--header-gutter); + padding-right: var(--header-gutter); + } + + a { + width: 100%; + max-width: 100%; + text-decoration: none; + } + + :global { + .MuiListItemText-root { + margin-left: 10px; + } + + .MuiTypography-root { + overflow: hidden; + text-overflow: ellipsis; + } + } + + .viewAll span { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + + svg { + margin-left: 0.5rem; + transform: rotate(180deg); + } + } + + .divider { + height: 0; + margin: 10px var(--header-gutter); + } + } +} + +.asideSpacer { + width: 0; + transition: width 0.3s; + + &.asideSpacerOpen { + width: var(--sidebar-width); + } +} + +@media screen and (max-width: 1200px) { + .asideSpacer { + display: none; + } +} + +.mobileUser { + display: flex; + flex-direction: column; + align-items: flex-start; + width: var(--sidebar-width); + min-width: var(--sidebar-width); + padding: var(--header-gutter); + + :global { + .themeToggle { + margin-left: -15px; + margin-bottom: 1rem; + } + } + @media screen and (min-width: 900px) { + display: none; + } +} diff --git a/app-frontend/react/src/components/SideBar/SideBar.tsx b/app-frontend/react/src/components/SideBar/SideBar.tsx new file mode 100644 index 0000000..954c713 --- /dev/null +++ b/app-frontend/react/src/components/SideBar/SideBar.tsx @@ -0,0 +1,226 @@ +import { Link } from "react-router-dom"; +import { useTheme } from "@mui/material/styles"; +import { SvgIconProps } from "@mui/material/SvgIcon"; +import styles from "./SideBar.module.scss"; +// import LogoutIcon from "@mui/icons-material/Logout"; +// import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +// import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; +import DatabaseIcon from "@icons/Database"; +import RecentIcon from "@icons/Recent"; +// import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; +// import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined"; +import { Box, ListItemText, MenuItem, MenuList } from "@mui/material"; + +import { JSX, MouseEventHandler } from "react"; +import ThemeToggle from "@components/Header_ThemeToggle/ThemeToggle"; + +import { useAppSelector } from "@redux/store"; +import { userSelector } from "@redux/User/userSlice"; +import { + conversationSelector, + newConversation, +} from "@redux/Conversation/ConversationSlice"; +import { Conversation } from "@redux/Conversation/Conversation"; +// import { useKeycloak } from "@react-keycloak/web"; +// import UploadChat from "@components/SideBar_UploadChat/UploadChat"; +import { KeyboardBackspace } from "@mui/icons-material"; +import { useDispatch } from "react-redux"; + +interface SideBarProps { + asideOpen: boolean; + setAsideOpen?: (open: boolean) => void; + userDetails?: () => JSX.Element; +} + +interface NavIconProps { + component: React.ComponentType; +} + +export const NavIcon: React.FC = ({ + component: ListItemIcon, +}) => { + const theme = useTheme(); + return ; +}; + +const EmptySvg: React.FC = () => { + return ( + + ); +}; + +interface LinkedMenuItemProps { + to: string; + children: React.ReactNode; + onClick?: MouseEventHandler; + sx?: any; + open?: boolean; +} + +export const LinkedMenuItem: React.FC = ({ + to, + children, + onClick, + sx, + open, +}) => { + return ( + + + {children} + + + ); +}; + +const SideBar: React.FC = ({ + asideOpen, + setAsideOpen = () => {}, + userDetails, +}) => { + const dispatch = useDispatch(); + const theme = useTheme(); + + // const { keycloak } = useKeycloak(); + const { role } = useAppSelector(userSelector); + const { conversations } = useAppSelector(conversationSelector); + + const asideBackgroundColor = { + backgroundColor: theme.customStyles.aside?.main, + }; + + const dividerColor = { + borderBottom: `1px solid ${theme.customStyles.customDivider?.main}`, + }; + + const handleLinkedMenuItemClick = ( + event: React.MouseEvent, + ) => { + event.currentTarget.blur(); // so we can apply the aria hidden attribute while menu closed + dispatch(newConversation(true)); + setAsideOpen(false); + }; + + const history = (type: Conversation[]) => { + if (type && type.length > 0) { + return type.map((conversation: Conversation, index: number) => { + if (index > 2) return null; + return ( + + + {conversation.first_query} + + ); + }); + } + }; + + // const handleLogout = () => { + // // keycloak.logout(); + // setAsideOpen(false); + // }; + + const viewAll = (path: string) => { + if (conversations.length > 0) { + return ( + + + + View All + + + ); + } else { + return ( + + + No recent conversations + + ); + } + }; + + return ( + + ); +}; + +const SideBarSpacer: React.FC = ({ asideOpen }) => { + return ( +
+ ); +}; + +export { SideBar, SideBarSpacer }; diff --git a/app-frontend/react/src/components/SideBar_UploadChat/UploadChat.tsx b/app-frontend/react/src/components/SideBar_UploadChat/UploadChat.tsx new file mode 100644 index 0000000..17ab636 --- /dev/null +++ b/app-frontend/react/src/components/SideBar_UploadChat/UploadChat.tsx @@ -0,0 +1,141 @@ +import { + NotificationSeverity, + notify, +} from "@components/Notification/Notification"; +import { LinkedMenuItem, NavIcon } from "@components/SideBar/SideBar"; +import { FileUploadOutlined } from "@mui/icons-material"; +import { ListItemText } from "@mui/material"; +import { + conversationSelector, + getAllConversations, + saveConversationtoDatabase, + uploadChat, +} from "@redux/Conversation/ConversationSlice"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { userSelector } from "@redux/User/userSlice"; +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; + +interface UploadChatProps { + asideOpen: boolean; + setAsideOpen: (open: boolean) => void; +} + +const UploadChat: React.FC = ({ asideOpen, setAsideOpen }) => { + const dispatch = useAppDispatch(); + const { selectedConversationHistory } = useAppSelector(conversationSelector); + + const navigate = useNavigate(); + + const fileInputRef = useRef(null); + const newUpload = useRef(false); + + useEffect(() => { + if (newUpload.current && selectedConversationHistory) { + saveConversation(); + } + }, [selectedConversationHistory]); + + const handleUploadClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.currentTarget.blur(); // so we can apply the aria hidden attribute while menu closed + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const saveConversation = async () => { + try { + const resultAction = await dispatch( + saveConversationtoDatabase({ conversation: { id: "" } }), + ); + + if (saveConversationtoDatabase.fulfilled.match(resultAction)) { + const responseData = resultAction.payload; + setAsideOpen(false); + newUpload.current = false; + notify( + "Conversation successfully uploaded", + NotificationSeverity.SUCCESS, + ); + navigate(`/chat/${responseData}`); + } else { + newUpload.current = false; + notify("Error saving conversation", NotificationSeverity.ERROR); + console.error("Error saving conversation:", resultAction.error); + } + } catch (error) { + newUpload.current = false; + notify("Error saving conversation", NotificationSeverity.ERROR); + console.error("An unexpected error occurred:", error); + } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (file) { + newUpload.current = true; + const reader = new FileReader(); + + reader.onload = () => { + try { + const fileContent = JSON.parse(reader.result as string); + + if ( + !fileContent.messages || + !fileContent.model || + !fileContent.token || + !fileContent.temperature || + fileContent.type + ) { + throw "Incorrect Format"; + } + + dispatch( + uploadChat({ + messages: fileContent.messages, + model: fileContent.model, + token: fileContent.token, + temperature: fileContent.temperature, + type: fileContent.type, + }), + ); + } catch (error) { + notify( + `Error parsing JSON file: ${error}`, + NotificationSeverity.ERROR, + ); + console.error("Error parsing JSON file:", error); + } + }; + + reader.readAsText(file); + } + }; + + return ( + <> + + + Upload Chat + + + {/* Hidden file input */} + + + ); +}; + +export default UploadChat; diff --git a/app-frontend/react/src/components/Summary_WebInput/WebInput.module.scss b/app-frontend/react/src/components/Summary_WebInput/WebInput.module.scss new file mode 100644 index 0000000..069a27a --- /dev/null +++ b/app-frontend/react/src/components/Summary_WebInput/WebInput.module.scss @@ -0,0 +1,19 @@ +.inputWrapper { + width: 100%; + max-width: 700px; + margin-top: 1rem; +} + +.dataList { + width: 100%; + margin-top: 2rem; + max-height: 300; + overflow: auto; + border: 1px solid; + border-radius: 8px; + padding: 1rem; + + li:not(:last-of-type) { + margin-bottom: 1rem; + } +} diff --git a/app-frontend/react/src/components/Summary_WebInput/WebInput.tsx b/app-frontend/react/src/components/Summary_WebInput/WebInput.tsx new file mode 100644 index 0000000..693e824 --- /dev/null +++ b/app-frontend/react/src/components/Summary_WebInput/WebInput.tsx @@ -0,0 +1,120 @@ +import { AddCircle, Delete } from "@mui/icons-material"; +import { + IconButton, + InputAdornment, + List, + ListItem, + ListItemText, + styled, + TextField, + useTheme, +} from "@mui/material"; +import { useState } from "react"; +import styles from "./WebInput.module.scss"; +import { Language } from "@mui/icons-material"; + +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + conversationSelector, + setSourceLinks, +} from "@redux/Conversation/ConversationSlice"; + +export const CustomTextInput = styled(TextField)(({ theme }) => ({ + ...theme.customStyles.webInput, +})); + +export const AddIcon = styled(AddCircle)(({ theme }) => ({ + path: { + fill: theme.customStyles.icon?.main, + }, +})); + +const WebInput = () => { + const [inputValue, setInputValue] = useState(""); + + const theme = useTheme(); + + const { sourceLinks } = useAppSelector(conversationSelector); + const dispatch = useAppDispatch(); + + const handleAdd = (newSource: string) => { + if (!newSource) return; + const prevSource = sourceLinks ?? []; + dispatch(setSourceLinks([...prevSource, newSource])); + setInputValue(""); + }; + + const handleDelete = (index: number) => { + const newSource = sourceLinks.filter((s: any, i: number) => i !== index); + dispatch(setSourceLinks([...newSource])); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue) { + handleAdd(inputValue); + } + }; + + const handleIconClick = () => { + if (inputValue) { + handleAdd(inputValue); + } + }; + + const sourcesDisplay = () => { + if (!sourceLinks || sourceLinks.length === 0) return; + + return ( + + {sourceLinks.map((sourceItem: string, index: number) => ( + handleDelete(index)}> + + + } + > + 30 + ? `${sourceItem.substring(0, 27)}...` + : sourceItem + } + /> + + + ))} + + ); + }; + + return ( +
+ ) => + setInputValue(e.target.value) + } + InputProps={{ + endAdornment: ( + + + + ), + }} + fullWidth + /> + + {sourcesDisplay()} +
+ ); +}; + +export default WebInput; diff --git a/app-frontend/react/src/components/UserInfoModal/UserInfoModal.tsx b/app-frontend/react/src/components/UserInfoModal/UserInfoModal.tsx deleted file mode 100644 index d958708..0000000 --- a/app-frontend/react/src/components/UserInfoModal/UserInfoModal.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -// import { SyntheticEvent, useEffect, useState } from 'react' -// import { useDisclosure } from '@mantine/hooks'; -// import { TextInput, Button, Modal } from '@mantine/core'; -// import { useDispatch, useSelector } from 'react-redux'; -// import { userSelector, setUser } from '../../redux/User/userSlice'; - -import { useDispatch } from 'react-redux'; -import { setUser } from '../../redux/User/userSlice'; - - -const UserInfoModal = () => { - // const [opened, { open, close }] = useDisclosure(false); - // const { name } = useSelector(userSelector); - const username = "OPEA Studio User"; - // const [username, setUsername] = useState(name || ""); - - const dispatch = useDispatch(); - dispatch(setUser(username)); - - // const handleSubmit = (event: SyntheticEvent) => { - // event.preventDefault() - // if(username){ - // close(); - // dispatch(setUser(username)); - // setUsername("") - // } - - // } - // useEffect(() => { - // if (!name) { - // open(); - // } - // }, [name]) - return ( - <> - {/* handleSubmit} title="Tell us who you are ?" centered> - <> -
- setUsername(event?.currentTarget.value)} value={username} data-autofocus /> - - - - -
*/} - - - ) -} - -export default UserInfoModal \ No newline at end of file diff --git a/app-frontend/react/src/components/sidebar/sidebar.module.scss b/app-frontend/react/src/components/sidebar/sidebar.module.scss deleted file mode 100644 index b58a253..0000000 --- a/app-frontend/react/src/components/sidebar/sidebar.module.scss +++ /dev/null @@ -1,84 +0,0 @@ -/** - Copyright (c) 2024 Intel Corporation - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - **/ - -@import "../../styles/styles"; - -.navbar { - width: 100%; - @include flex(column, nowrap, center, flex-start); - padding: var(--mantine-spacing-md); - background-color: var(--mantine-color-blue-filled); - // background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); - // border-right: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); -} - -.navbarMain { - flex: 1; -} - -.navbarLogo { - width: 100%; - display: flex; - justify-content: center; - padding-top: var(--mantine-spacing-md); - margin-bottom: var(--mantine-spacing-xl); -} - -.link { - width: 44px; - height: 44px; - border-radius: var(--mantine-radius-md); - display: flex; - align-items: center; - justify-content: center; - color: var(--mantine-color-white); - - &:hover { - background-color: var(--mantine-color-blue-7); - } - - &[data-active] { - &, - &:hover { - box-shadow: var(--mantine-shadow-sm); - background-color: var(--mantine-color-white); - color: var(--mantine-color-blue-6); - } - } -} - -.aside { - flex: 0 0 60px; - background-color: var(--mantine-color-body); - display: flex; - flex-direction: column; - align-items: center; - border-right: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7)); -} - -.logo { - width: 100%; - display: flex; - justify-content: center; - height: 60px; - padding-top: var(--mantine-spacing-s); - border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7)); - margin-bottom: var(--mantine-spacing-xl); -} -.logoImg { - width: 30px; -} diff --git a/app-frontend/react/src/components/sidebar/sidebar.tsx b/app-frontend/react/src/components/sidebar/sidebar.tsx deleted file mode 100644 index e5e9349..0000000 --- a/app-frontend/react/src/components/sidebar/sidebar.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import { useState } from "react" -import { Tooltip, UnstyledButton, Stack, rem } from "@mantine/core" -import { IconHome2, IconLogout } from "@tabler/icons-react" -import classes from "./sidebar.module.scss" -import OpeaLogo from "../../assets/opea-icon-black.svg" -import { useAppDispatch } from "../../redux/store" -import { removeUser } from "../../redux/User/userSlice" -import { logout } from "../../redux/Conversation/ConversationSlice" - -interface NavbarLinkProps { - icon: typeof IconHome2 - label: string - active?: boolean - onClick?(): void -} - -function NavbarLink({ icon: Icon, label, active, onClick }: NavbarLinkProps) { - return ( - - - - - - ) -} - -export interface SidebarNavItem { - icon: typeof IconHome2 - label: string -} - -export type SidebarNavList = SidebarNavItem[] - -export interface SideNavbarProps { - navList: SidebarNavList -} - -export function SideNavbar({ navList }: SideNavbarProps) { - const dispatch =useAppDispatch() - const [active, setActive] = useState(0) - - const handleLogout = () => { - dispatch(logout()) - dispatch(removeUser()) - } - - const links = navList.map((link, index) => ( - setActive(index)} /> - )) - - return ( - - ) -} diff --git a/app-frontend/react/src/config.ts b/app-frontend/react/src/config.ts index 281cab7..ffae0bb 100644 --- a/app-frontend/react/src/config.ts +++ b/app-frontend/react/src/config.ts @@ -1,21 +1,55 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -// console.log("Environment variables:", import.meta.env); +const config = { + companyName: "OPEA Studio", + logo: "/logo512.png", + tagline: "what can I help you today?", + disclaimer: + "

Generative AI provides significant benefits for enhancing the productivity of quality engineers, production support teams, software developers, and DevOps professionals. With a secure and scalable toolbox, it offers a flexible architecture capable of connecting to various data sources and models, enabling it to address a wide range of Generative AI use cases.

This platform saves your user ID to retain chat history, which you can choose to delete from the app at any time.

", + defaultChatPrompt: `You are a helpful assistant`, +}; -export const APP_UUID = import.meta.env.VITE_APP_UUID; -export const CHAT_QNA_URL = "VITE_APP_BACKEND_SERVICE_URL"; -export const DATA_PREP_URL = "VITE_APP_DATA_PREP_SERVICE_URL"; +export default config; -type UiFeatures = { - dataprep: boolean; - chat: boolean; -}; -export const UI_FEATURES: UiFeatures = { - chat: CHAT_QNA_URL.startsWith('VITE_') ? false : true, - dataprep: DATA_PREP_URL.startsWith('VITE_') ? false : true -}; -console.log("chat qna", CHAT_QNA_URL, UI_FEATURES.chat); -console.log("data prep", DATA_PREP_URL, UI_FEATURES.dataprep); \ No newline at end of file +// export const CHAT_QNA_URL = import.meta.env.VITE_BACKEND_SERVICE_ENDPOINT_CHATQNA; +export const CHAT_QNA_URL = import.meta.env.VITE_BACKEND_SERVICE_URL +// export const CODE_GEN_URL = import.meta.env.VITE_BACKEND_SERVICE_ENDPOINT_CODEGEN; +export const CODE_GEN_URL = import.meta.env.VITE_BACKEND_SERVICE_URL +// export const DOC_SUM_URL = import.meta.env.VITE_BACKEND_SERVICE_ENDPOINT_DOCSUM; +export const DOC_SUM_URL = import.meta.env.VITE_BACKEND_SERVICE_URL +export const UI_SELECTION = import.meta.env.VITE_UI_SELECTION; + +console.log ("BACKEND_SERVICE_URL", import.meta.env.VITE_BACKEND_SERVICE_URL); +console.log ("DATA_PREP_SERVICE_URL", import.meta.env.VITE_DATAPREP_SERVICE_URL); +console.log ("CHAT_HISTORY_SERVICE_URL", import.meta.env.VITE_CHAT_HISTORY_SERVICE_URL); +console.log ("UI_SELECTION", import.meta.env.VITE_UI_SELECTION); + +// export const FAQ_GEN_URL = import.meta.env.VITE_BACKEND_SERVICE_ENDPOINT_FAQGEN; +export const DATA_PREP_URL = import.meta.env.VITE_DATAPREP_SERVICE_URL; +// export const DATA_PREP_URL = "http://localhost:6007/v1/dataprep/"; +export const DATA_PREP_INGEST_URL = DATA_PREP_URL + "/ingest"; +export const DATA_PREP_GET_URL = DATA_PREP_URL + "/get"; +export const DATA_PREP_DELETE_URL = DATA_PREP_URL + "/delete"; + +console.log ("DATA_PREP_INGEST_URL", DATA_PREP_INGEST_URL); +console.log ("DATA_PREP_GET_URL", DATA_PREP_GET_URL); +console.log ("DATA_PREP_DELETE_URL", DATA_PREP_DELETE_URL); + +export const CHAT_HISTORY_URL = import.meta.env.VITE_CHAT_HISTORY_SERVICE_URL; +// export const CHAT_HISTORY_URL = "http://localhost:6012/v1/chathistory/"; +export const CHAT_HISTORY_CREATE = CHAT_HISTORY_URL + "/create"; +export const CHAT_HISTORY_GET = CHAT_HISTORY_URL + "/get"; +export const CHAT_HISTORY_DELETE = CHAT_HISTORY_URL + "/delete"; + +console.log ("CHAT_HISTORY_CREATE", CHAT_HISTORY_CREATE); +console.log ("CHAT_HISTORY_GET", CHAT_HISTORY_GET); +console.log ("CHAT_HISTORY_DELETE", CHAT_HISTORY_DELETE); + +export const PROMPT_MANAGER_GET = import.meta.env.VITE_PROMPT_SERVICE_GET_ENDPOINT; +export const PROMPT_MANAGER_CREATE = import.meta.env.VITE_PROMPT_SERVICE_CREATE_ENDPOINT; +export const PROMPT_MANAGER_DELETE = import.meta.env.VITE_PROMPT_SERVICE_DELETE_ENDPOINT; + + diff --git a/app-frontend/react/src/contexts/ThemeContext.tsx b/app-frontend/react/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..94ec15b --- /dev/null +++ b/app-frontend/react/src/contexts/ThemeContext.tsx @@ -0,0 +1,39 @@ +import React, { createContext, useState, useEffect, useCallback } from "react"; +import { ThemeProvider as MuiThemeProvider, CssBaseline } from "@mui/material"; +import { themeCreator } from "../theme/theme"; + +interface ThemeContextType { + darkMode: boolean; + toggleTheme: () => void; +} + +export const ThemeContext = createContext({ + darkMode: false, + toggleTheme: () => {}, +}); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const savedTheme = localStorage.getItem("theme") === "dark"; + const [darkMode, setDarkMode] = useState(savedTheme); + + const toggleTheme = useCallback(() => { + setDarkMode((prevMode) => !prevMode); + }, []); + + useEffect(() => { + localStorage.setItem("theme", darkMode ? "dark" : "light"); + }, [darkMode]); + + const theme = themeCreator(darkMode ? "dark" : "light"); + + return ( + + + + {children} + + + ); +}; diff --git a/app-frontend/react/src/icons/Atom.tsx b/app-frontend/react/src/icons/Atom.tsx new file mode 100644 index 0000000..039b640 --- /dev/null +++ b/app-frontend/react/src/icons/Atom.tsx @@ -0,0 +1,134 @@ +import { useTheme } from "@mui/material"; + +interface AtomIconProps { + className?: string; +} + +const AtomIcon: React.FC = ({ className }) => { + const theme = useTheme(); + + const iconColor = theme.customStyles.icon?.main; + + return ( + + + + + ); +}; + +const AtomAnimation: React.FC = ({ className }) => { + const theme = useTheme(); + + const iconColor = theme.customStyles.icon?.main; + + return ( + + + {/* Grouping each ellipse with a circle */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export { AtomAnimation, AtomIcon }; diff --git a/app-frontend/react/src/icons/ChatBubble.tsx b/app-frontend/react/src/icons/ChatBubble.tsx new file mode 100644 index 0000000..9ba2c4a --- /dev/null +++ b/app-frontend/react/src/icons/ChatBubble.tsx @@ -0,0 +1,38 @@ +import { useTheme } from "@mui/material"; + +interface ChatBubbleIconProps { + className?: string; +} + +const ChatBubbleIcon: React.FC = ({ className }) => { + const theme = useTheme(); + + const iconColor = theme.customStyles.icon?.main; + + return ( + + + + + ); +}; + +export default ChatBubbleIcon; diff --git a/app-frontend/react/src/icons/Database.tsx b/app-frontend/react/src/icons/Database.tsx new file mode 100644 index 0000000..a74130d --- /dev/null +++ b/app-frontend/react/src/icons/Database.tsx @@ -0,0 +1,29 @@ +import { SvgIcon, useTheme } from "@mui/material"; + +interface DatabaseIconProps { + className?: string; +} + +const DatabaseIcon: React.FC = ({ className }) => { + const theme = useTheme(); + const iconColor = theme.customStyles.icon?.main; + + return ( + + + + + + ); +}; + +export default DatabaseIcon; diff --git a/app-frontend/react/src/icons/Recent.tsx b/app-frontend/react/src/icons/Recent.tsx new file mode 100644 index 0000000..6018916 --- /dev/null +++ b/app-frontend/react/src/icons/Recent.tsx @@ -0,0 +1,29 @@ +import { SvgIcon, useTheme } from "@mui/material"; + +interface RecentIconProps { + className?: string; +} + +const RecentIcon: React.FC = ({ className }) => { + const theme = useTheme(); + const iconColor = theme.customStyles.icon?.main; + + return ( + + + + + + ); +}; + +export default RecentIcon; diff --git a/app-frontend/react/src/icons/Waiting.tsx b/app-frontend/react/src/icons/Waiting.tsx new file mode 100644 index 0000000..f2767d6 --- /dev/null +++ b/app-frontend/react/src/icons/Waiting.tsx @@ -0,0 +1,45 @@ +import { useTheme } from "styled-components"; + +const WaitingIcon = () => { + const theme = useTheme(); + const iconColor = theme.customStyles.icon?.main; + + return ( + + + + + + + + + + + + ); +}; + +export default WaitingIcon; diff --git a/app-frontend/react/src/index.scss b/app-frontend/react/src/index.scss index 53e7162..bf8ec54 100644 --- a/app-frontend/react/src/index.scss +++ b/app-frontend/react/src/index.scss @@ -1,20 +1,56 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 +// Before javascript styles -@import "@mantine/core/styles.css"; +html { + font-size: 16px; +} -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; +body { + margin: 0; + font-family: "Inter", serif; + font-optical-sizing: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + height: 100vh; line-height: 1.5; - font-weight: 400; } -html, -body { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; +#root { + display: flex; + flex-direction: column; + height: 100vh; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} + +:root { + --header-height: 72px; + --header-gutter: 1.5rem; + --sidebar-width: 260px; + --vertical-spacer: 2rem; + --content-width: 800px; + --content-gutter: 3rem; + --input-radius: 30px; + --copy-color: #3d447f; +} + +::-webkit-scrollbar { + background: transparent; + width: 10px; +} + +/* Style the thumb (the draggable part of the scrollbar) */ +::-webkit-scrollbar-thumb { + height: 20px; + background-color: rgba(0, 0, 0, 0.3); + /* Thumb color */ + border-radius: 5px; + /* Optional, for rounded corners */ +} + +/* Optionally, you can add hover effects for the thumb */ +::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.5); + /* Darker thumb when hovered */ } diff --git a/app-frontend/react/src/index.tsx b/app-frontend/react/src/index.tsx new file mode 100644 index 0000000..910a386 --- /dev/null +++ b/app-frontend/react/src/index.tsx @@ -0,0 +1,24 @@ +// import React from "react"; +import { createRoot } from "react-dom/client"; +import "./index.scss"; +import App from "./App"; +import { Provider } from "react-redux"; +import { store } from "@redux/store"; +import { ThemeProvider } from "@contexts/ThemeContext"; +// import keycloak from "@root/keycloak"; +// import { ReactKeycloakProvider } from "@react-keycloak/web"; + +const root = createRoot(document.getElementById("root")!); +root.render( + //@ts-ignore + // + + + + + + // , +); diff --git a/app-frontend/react/src/layouts/Main/MainLayout.module.scss b/app-frontend/react/src/layouts/Main/MainLayout.module.scss new file mode 100644 index 0000000..0736eaa --- /dev/null +++ b/app-frontend/react/src/layouts/Main/MainLayout.module.scss @@ -0,0 +1,21 @@ +.mainLayout { + height: 100%; + display: flex; + flex-direction: column; + max-height: 100%; + overflow: hidden; +} + +.mainWrapper { + display: flex; + flex-direction: row; + flex-grow: 1; + max-height: 100%; + overflow: auto; + overflow-x: hidden; +} + +.contentWrapper { + max-height: 100%; + width: 100%; +} diff --git a/app-frontend/react/src/layouts/Main/MainLayout.tsx b/app-frontend/react/src/layouts/Main/MainLayout.tsx new file mode 100644 index 0000000..2965e70 --- /dev/null +++ b/app-frontend/react/src/layouts/Main/MainLayout.tsx @@ -0,0 +1,39 @@ +import Header from "@components/Header/Header"; +import { SideBarSpacer } from "@components/SideBar/SideBar"; +import { useState } from "react"; +import { Outlet } from "react-router-dom"; +import styles from "./MainLayout.module.scss"; + +interface MainLayoutProps { + chatView?: boolean; + historyView?: boolean; + dataView?: boolean; +} + +const MainLayout: React.FC = ({ + chatView = false, + historyView = false, + dataView = false, +}) => { + const [asideOpen, setAsideOpen] = useState(false); + + return ( +
+
+
+ +
+ +
+
+
+ ); +}; + +export default MainLayout; diff --git a/app-frontend/react/src/layouts/Minimal/MinimalLayout.module.scss b/app-frontend/react/src/layouts/Minimal/MinimalLayout.module.scss new file mode 100644 index 0000000..2a2ff37 --- /dev/null +++ b/app-frontend/react/src/layouts/Minimal/MinimalLayout.module.scss @@ -0,0 +1,10 @@ +.minimalLayout { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + padding: 2rem; + text-align: center; +} diff --git a/app-frontend/react/src/layouts/Minimal/MinimalLayout.tsx b/app-frontend/react/src/layouts/Minimal/MinimalLayout.tsx new file mode 100644 index 0000000..0ccc5ae --- /dev/null +++ b/app-frontend/react/src/layouts/Minimal/MinimalLayout.tsx @@ -0,0 +1,13 @@ +// About pages or privacy policy are likely minimal layouts +import { Outlet } from "react-router-dom"; +import styles from "./MinimalLayout.module.scss"; + +const MinimalLayout = () => { + return ( +
+ +
+ ); +}; + +export default MinimalLayout; diff --git a/app-frontend/react/src/layouts/ProtectedRoute/ProtectedRoute.tsx b/app-frontend/react/src/layouts/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..521e766 --- /dev/null +++ b/app-frontend/react/src/layouts/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,29 @@ +import { useAppSelector } from "@redux/store"; +import { userSelector } from "@redux/User/userSlice"; +import React, { useEffect } from "react"; + +interface ProtectedRouteProps { + component: React.ComponentType; + requiredRoles: string[]; +} + +const ProtectedRoute: React.FC = ({ + component: Component, + requiredRoles, +}) => { + const { isAuthenticated, role } = useAppSelector(userSelector); + + const isAllowed = React.useMemo(() => { + return isAuthenticated && requiredRoles.includes(role); + }, [isAuthenticated, role, requiredRoles.join(",")]); + + if (!isAllowed) { + return ( +

Access Denied: You do not have permission to view this page.

+ ); + } + + return ; +}; + +export default ProtectedRoute; diff --git a/app-frontend/react/src/logo.svg b/app-frontend/react/src/logo.svg new file mode 100644 index 0000000..7901511 --- /dev/null +++ b/app-frontend/react/src/logo.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-frontend/react/src/main.tsx b/app-frontend/react/src/main.tsx deleted file mode 100644 index 3d9c915..0000000 --- a/app-frontend/react/src/main.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import React from "react" -import ReactDOM from "react-dom/client" -import App from "./App.tsx" -import "./index.scss" -import { Provider } from 'react-redux' -import { store } from "./redux/store.ts" - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - -) diff --git a/app-frontend/react/src/pages/Chat/ChatView.module.scss b/app-frontend/react/src/pages/Chat/ChatView.module.scss new file mode 100644 index 0000000..d2c5579 --- /dev/null +++ b/app-frontend/react/src/pages/Chat/ChatView.module.scss @@ -0,0 +1,47 @@ +.chatView { + display: flex; + flex-direction: column; + max-height: 100%; + height: 100%; + + .messagesWrapper { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: calc(var(--header-gutter) * 2) calc(var(--header-gutter)); + max-height: 100%; + overflow-x: auto; + + @media screen and (min-width: 1200px) { + padding: calc(var(--header-gutter) * 2); + } + + .messageContent { + width: 100%; + max-width: var(--content-width); + margin: 0px auto; + + pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + } + } + } + + .inputWrapper { + display: block; + margin: 0px auto; + padding: calc(var(--header-gutter) * 0.2) calc(var(--header-gutter) * 0.2); + max-width: calc((var(--header-gutter) * 2) + 800px); + width: 100%; + } + + .promptSettings { + display: block; + margin: 0px auto; + padding: calc(var(--header-gutter) * 0.2) calc(var(--header-gutter) * 0.2); + max-width: calc((var(--header-gutter) * 2) + 800px); + width: 100%; + } +} diff --git a/app-frontend/react/src/pages/Chat/ChatView.tsx b/app-frontend/react/src/pages/Chat/ChatView.tsx new file mode 100644 index 0000000..739d23a --- /dev/null +++ b/app-frontend/react/src/pages/Chat/ChatView.tsx @@ -0,0 +1,353 @@ +import { useEffect, useRef, JSX } from "react"; +import styles from "./ChatView.module.scss"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import { Box } from "@mui/material"; +import PrimaryInput from "@components/PrimaryInput/PrimaryInput"; + +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + abortStream, + conversationSelector, + doCodeGen, + doConversation, + doSummaryFaq, + getConversationHistory, + newConversation, + setSelectedConversationId, +} from "@redux/Conversation/ConversationSlice"; +import { userSelector } from "@redux/User/userSlice"; +import ChatUser from "@components/Chat_User/ChatUser"; +import ChatAssistant from "@components/Chat_Assistant/ChatAssistant"; +import PromptSettings from "@components/PromptSettings/PromptSettings"; +import { Message, MessageRole } from "@redux/Conversation/Conversation"; +import { getCurrentTimeStamp, readFilesAndSummarize } from "@utils/utils"; +import ChatSources from "@components/Chat_Sources/ChatSources"; + +const ChatView = () => { + const { name } = useAppSelector(userSelector); + const { + selectedConversationHistory, + type, + sourceLinks, + sourceFiles, + temperature, + token, + model, + systemPrompt, + selectedConversationId, + onGoingResult, + isPending, + } = useAppSelector(conversationSelector); + + const systemPromptObject: Message = { + role: MessageRole.System, + content: systemPrompt, + }; + + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + // existing chat + const { conversation_id } = useParams(); + + // new chat + const { state } = useLocation(); + const initialMessage = state?.initialMessage || null; + const isSummary = type === "summary" || false; + const isCodeGen = type === "code" || false; + const isChat = type === "chat" || false; + const isFaq = type === "faq" || false; + + const fromHome = useRef(false); + const newMessage = useRef(false); + + const scrollContainer = useRef(null); + const autoScroll = useRef(true); + const scrollTimeout = useRef(null); + + const messagesBeginRef = useRef(null); + const messagesEndRef = useRef(null); + + // Scroll to top of fetched message + const scrollToTop = () => { + messagesBeginRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + // Scroll to the latest message + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + const handleUserScroll = () => { + if (scrollContainer.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer.current; + + // Disable autoscroll if the user scrolls up significantly + if (scrollTop + clientHeight < scrollHeight - 50) { + autoScroll.current = false; + } else { + // Use a timeout to delay re-enabling autoscroll, preventing rapid toggling + if (scrollTimeout.current) clearTimeout(scrollTimeout.current); + scrollTimeout.current = setTimeout(() => { + autoScroll.current = true; + }, 500); // Delay auto-scroll reactivation + } + } + }; + + useEffect(() => { + const container = scrollContainer.current; + if (!container) return; + + container.addEventListener("scroll", handleUserScroll); + + return () => { + container.removeEventListener("scroll", handleUserScroll); + if (scrollTimeout.current) clearTimeout(scrollTimeout.current); + if (onGoingResult) dispatch(abortStream()); + console.log("Reset Convo, preserve settings"); + dispatch(newConversation(false)); + }; + }, []); + + useEffect(() => { + if (onGoingResult && autoScroll.current) { + scrollToBottom(); + } + }, [onGoingResult]); + + useEffect(() => { + if (!name) return; + + // reset view (not full reset) + // dispatch(newConversation(false)) // moved to useEffect unmount + + // convo starting, new conversation id inboud + if (!conversation_id) fromHome.current = true; + + // existing convo, load and scroll up + if (conversation_id && conversation_id !== "new") { + dispatch(setSelectedConversationId(conversation_id)); + dispatch( + getConversationHistory({ user: name, conversationId: conversation_id }), + ); + scrollToTop(); + return; + } else if (conversation_id === "new") { + // new convo + fromHome.current = true; + + if ( + (isSummary || isFaq) && + ((sourceLinks && sourceLinks.length > 0) || + (sourceFiles && sourceFiles.length > 0) || + initialMessage) + ) { + // console.log('SUMMARY/FAQ') + newSummaryOrFaq(); + return; + } + + if (isCodeGen && initialMessage) { + // console.log('CODE') + newCodeGen(); + return; + } + + if (isChat && initialMessage) { + // console.log('NEW CHAT') + newChat(); + return; + } + + // no match for view, go home + console.log("Go Home"); + navigate("/"); + } + }, [conversation_id, name]); + + const newSummaryOrFaq = async () => { + const userPrompt: Message = { + role: MessageRole.User, + content: initialMessage, + time: getCurrentTimeStamp().toString(), + }; + + let prompt = { + conversationId: selectedConversationId, + userPrompt, + messages: initialMessage, + model, + files: sourceFiles, + temperature, + token, + type, // TODO: cannot past type + }; + + doSummaryFaq(prompt); + }; + + const newChat = () => { + const userPrompt: Message = { + role: MessageRole.User, + content: initialMessage, + time: getCurrentTimeStamp().toString(), + }; + + let messages: Message[] = []; + messages = [systemPromptObject, ...selectedConversationHistory]; + + let prompt = { + conversationId: selectedConversationId, + userPrompt, + messages, + model, + temperature, + token, + time: getCurrentTimeStamp().toString(), // TODO: cannot past time + type, // TODO: cannot past type + }; + + doConversation(prompt); + }; + + const newCodeGen = () => { + const userPrompt: Message = { + role: MessageRole.User, + content: initialMessage, + time: getCurrentTimeStamp().toString(), + }; + + let prompt = { + conversationId: selectedConversationId, + userPrompt: userPrompt, + messages: [], + model, + temperature, + token, + time: getCurrentTimeStamp().toString(), // TODO: cannot past time + type, // TODO: cannot past type + }; + + doCodeGen(prompt); + }; + + // ADD to existing conversation + const addMessage = (query: string) => { + const userPrompt: Message = { + role: MessageRole.User, + content: query, + time: getCurrentTimeStamp().toString(), + }; + + let messages: Message[] = []; + + messages = [...selectedConversationHistory]; + + let prompt = { + conversationId: selectedConversationId, + userPrompt, + messages, + model, + temperature, + token, + type, + }; + + doConversation(prompt); + }; + + const handleSendMessage = async (messageContent: string) => { + newMessage.current = true; + addMessage(messageContent); + }; + + const displayChatUser = (message: Message) => { + // file post will not have message, will display file.extension instead + if ((isSummary || isFaq) && !message.content) return; + + // normal message + if (message.role === MessageRole.User) { + return ; + } + }; + + const displayMessage = () => { + let messagesDisplay: JSX.Element[] = []; + + selectedConversationHistory.map((message, messageIndex) => { + const timestamp = message.time || Math.random(); + if (message.role !== MessageRole.System) { + messagesDisplay.push( + + {displayChatUser(message)} + {message.role === MessageRole.Assistant && ( + + )} + , + ); + } + }); + + if (onGoingResult) { + const continueMessage: Message = { + role: MessageRole.Assistant, + content: onGoingResult, + time: Date.now().toString(), + }; + + messagesDisplay.push( + + + , + ); + } else if (isPending) { + const continueMessage: Message = { + role: MessageRole.Assistant, + content: "", + time: Date.now().toString(), + }; + + messagesDisplay.push( + + + , + ); + } + + return messagesDisplay; + }; + + return !selectedConversationHistory ? ( + <> + ) : ( +
+
+
+ + + + {displayMessage()} + +
+
+ +
+ +
+
+ +
+
+ ); +}; + +export default ChatView; diff --git a/app-frontend/react/src/pages/DataSource/DataSourceManagement.module.scss b/app-frontend/react/src/pages/DataSource/DataSourceManagement.module.scss new file mode 100644 index 0000000..28e6863 --- /dev/null +++ b/app-frontend/react/src/pages/DataSource/DataSourceManagement.module.scss @@ -0,0 +1,71 @@ +.dataView { + height: 100%; + width: 100%; + max-width: var(--content-width); + width: 100%; + margin: 0px auto; + padding: calc(var(--header-gutter) * 2); +} + +.dataItem { + margin-bottom: 1rem; + position: relative; + padding: 0; + + :global { + .MuiCheckbox-root { + position: absolute; + right: 100%; + margin-right: 0.25rem; + top: 50%; + transform: translateY(-50%); + @media screen and (min-width: 901px) { + margin-right: 1rem; + } + } + } +} + +.dataName { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + width: 100%; + padding: 1rem; + margin: 0; + line-height: 1.5; +} + +.searchInput { + width: 100%; + margin-bottom: 1rem; + background: none; + + // padding: var(--header-gutter); + border: 0; + margin-bottom: 2rem; + // margin-right: 45px; + &:focus { + outline: none; + } + + :global { + .MuiInputBase-root { + border-radius: var(--input-radius); + } + } +} + +.dataInputWrapper { + width: 100%; + margin-top: 2rem; + margin-bottom: 2rem; + display: flex; + flex-direction: column; + align-items: center; +} + +.actions button { + margin-left: 0.5rem; +} diff --git a/app-frontend/react/src/pages/DataSource/DataSourceManagement.tsx b/app-frontend/react/src/pages/DataSource/DataSourceManagement.tsx new file mode 100644 index 0000000..cb0fe9a --- /dev/null +++ b/app-frontend/react/src/pages/DataSource/DataSourceManagement.tsx @@ -0,0 +1,242 @@ +import { + Box, + Checkbox, + FormControlLabel, + List, + ListItem, + Typography, + FormGroup, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useEffect, useState } from "react"; +import styles from "./DataSourceManagement.module.scss"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + conversationSelector, + deleteInDataSource, + getAllFilesInDataSource, + deleteMultipleInDataSource, +} from "@redux/Conversation/ConversationSlice"; +import { file } from "@redux/Conversation/Conversation"; +import DropDown from "@components/DropDown/DropDown"; +import DataWebInput from "@components/Data_Web/DataWebInput"; +import FileInput from "@components/File_Input/FileInput"; +import SearchInput from "@components/SearchInput/SearchInput"; +import { + DeleteButton, + SolidButton, + TextButton, +} from "@root/shared/ActionButtons"; + +const DataSourceManagement = () => { + const dispatch = useAppDispatch(); + + const theme = useTheme(); + + const { filesInDataSource } = useAppSelector(conversationSelector); + + const [dataList, setDataList] = useState([]); + const [activeSourceType, setActiveSourceType] = useState("documents"); + const [selectActive, setSelectActive] = useState(false); + const [selectAll, setSelectAll] = useState(false); + const [checkedItems, setCheckedItems] = useState>({}); + + useEffect(() => { + dispatch(getAllFilesInDataSource({ knowledgeBaseId: "default" })); + }, []); + + const sortFiles = () => { + if (activeSourceType === "web") { + let webFiles = filesInDataSource.filter((file) => + file.name.startsWith("http"), + ); + return webFiles; + } else { + let otherFiles = filesInDataSource.filter( + (file) => !file.name.startsWith("http"), + ); + return otherFiles; + } + }; + + useEffect(() => { + setDataList(sortFiles()); + }, [filesInDataSource, activeSourceType]); + + const handleCheckboxChange = (conversationId: string) => { + setCheckedItems((prev) => ({ + ...prev, + [conversationId]: !prev[conversationId], + })); + }; + + const displayFiles = () => { + return dataList.map((file: file) => { + const isChecked = !!checkedItems[file.id]; + + const fileText = ( + <> + {file.name} + {/* TODO: timestamp for all conversations? */} + {/* Last message {convertTime(conversation.updated_at)} */} + + ); + + const controlCheckBox = ( + handleCheckboxChange(file.id)} + checked={isChecked} + /> + ); + + return ( + + {selectActive ? ( + + ) : ( + + {fileText} + + )} + + ); + }); + }; + + const cancelSelect = () => { + setSelectActive(false); + setSelectAll(false); + setCheckedItems({}); + }; + + const deleteSelected = () => { + setSelectActive(false); + + let files = []; + for (const [key, value] of Object.entries(checkedItems)) { + if (value === true) { + files.push(key); + } + } + + if (files.length > 0) { + //update current state + setDataList((prev) => prev.filter((item) => !checkedItems[item.id])); + dispatch(deleteMultipleInDataSource({ files: files })); + } + }; + + const handleSelectAll = () => { + const newSelectAll = !selectAll; + setSelectAll(newSelectAll); + + // Add all items' checked state + const updatedCheckedItems: Record = {}; + dataList.forEach((file) => { + updatedCheckedItems[file.id] = newSelectAll; + }); + + setCheckedItems(updatedCheckedItems); + }; + + const handleSearch = (value: string) => { + const filteredList = dataList; + const searchResults = filteredList.filter((file: file) => + file.name?.toLowerCase().includes(value.toLowerCase()), + ); + setDataList(value ? searchResults : sortFiles()); + }; + + const updateSource = (value: string) => { + setActiveSourceType(value); + }; + + const displayInput = () => { + let input = null; + if (activeSourceType === "documents") + input = ; + if (activeSourceType === "web") input = ; + if (activeSourceType === "images") + input = ; + + return {input}; + }; + + return ( + + + + + } + /> + + + + {displayInput()} + + + + + + You have {dataList.length} file{dataList.length !== 1 && "s"} + + + {dataList.length > 0 && ( + + {selectActive ? ( + handleSelectAll()}> + Select All + + ) : ( + setSelectActive(true)}> + Select + + )} + + {selectActive && ( + <> + cancelSelect()}>Cancel + deleteSelected()}> + Delete Selected + + + )} + + )} + + + {displayFiles()} + + ); +}; + +export default DataSourceManagement; diff --git a/app-frontend/react/src/pages/History/HistoryView.module.scss b/app-frontend/react/src/pages/History/HistoryView.module.scss new file mode 100644 index 0000000..6c4c6d5 --- /dev/null +++ b/app-frontend/react/src/pages/History/HistoryView.module.scss @@ -0,0 +1,82 @@ +.historyView { + height: 100%; + width: 100%; + max-width: var(--content-width); + width: 100%; + margin: 0px auto; + padding: calc(var(--header-gutter) * 2); + + .historyListWrapper { + display: flex; + flex-direction: column; + align-items: center; + @media screen and (min-width: 901px) { + flex-direction: row; + justify-content: space-between; + } + } + + .actions button { + margin-left: 0.5rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + &:first-of-type { + margin-left: 0; + } + @media screen and (min-width: 901px) { + margin-left: 0.5rem; + margin-bottom: 0; + margin-top: 0; + &:first-of-type { + margin-left: 0.5rem; + } + } + } +} + +.historyItem { + margin-bottom: 1rem; + position: relative; + padding: 0; + + :global { + .MuiCheckbox-root { + position: absolute; + right: 100%; + margin-right: 0.25rem; + top: 50%; + transform: translateY(-50%); + + @media screen and (min-width: 901px) { + margin-right: 1rem; + } + } + } + + a { + text-decoration: none; + } + + .title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + max-width: 100%; + } +} + +.historyLink { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + width: 100%; + padding: 1rem; + margin: 0; + line-height: 1.5; +} diff --git a/app-frontend/react/src/pages/History/HistoryView.tsx b/app-frontend/react/src/pages/History/HistoryView.tsx new file mode 100644 index 0000000..3a59e5e --- /dev/null +++ b/app-frontend/react/src/pages/History/HistoryView.tsx @@ -0,0 +1,214 @@ +import { + Box, + Checkbox, + FormControlLabel, + List, + ListItem, + Typography, + Link, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useState } from "react"; +import styles from "./HistoryView.module.scss"; + +import { Link as RouterLink } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +import { + conversationSelector, + deleteConversation, + deleteConversations, +} from "@redux/Conversation/ConversationSlice"; +import { Conversation } from "@redux/Conversation/Conversation"; +import { userSelector } from "@redux/User/userSlice"; +import SearchInput from "@components/SearchInput/SearchInput"; +import { + DeleteButton, + SolidButton, + TextButton, +} from "@root/shared/ActionButtons"; + +interface HistoryViewProps { + shared: boolean; +} + +const HistoryView: React.FC = ({ shared }) => { + const dispatch = useAppDispatch(); + const { name } = useAppSelector(userSelector); + + const theme = useTheme(); + + const { conversations, sharedConversations } = + useAppSelector(conversationSelector); + + const [historyList, setHistoryList] = useState( + shared ? sharedConversations : conversations, + ); + const [selectActive, setSelectActive] = useState(false); + const [selectAll, setSelectAll] = useState(false); + const [checkedItems, setCheckedItems] = useState>({}); + + const convertTime = (timestamp: number) => { + const now = Math.floor(Date.now() / 1000); + const diffInSeconds = now - timestamp; + + const diffInMinutes = Math.floor(diffInSeconds / 60); + const diffInHours = Math.floor(diffInSeconds / 3600); + const diffInDays = Math.floor(diffInSeconds / 86400); + + if (diffInDays > 0) { + return `${diffInDays} day${diffInDays > 1 ? "s" : ""} ago`; + } else if (diffInHours > 0) { + return `${diffInHours} hour${diffInHours > 1 ? "s" : ""} ago`; + } else { + return `${diffInMinutes} minute${diffInMinutes > 1 ? "s" : ""} ago`; + } + }; + + const handleCheckboxChange = (conversationId: string) => { + setCheckedItems((prev) => ({ + ...prev, + [conversationId]: !prev[conversationId], + })); + }; + + const displayHistory = () => { + return historyList.map((conversation: Conversation) => { + const isChecked = !!checkedItems[conversation.id]; + + const itemText = ( + <> + + {conversation.first_query} + + {/* TODO: timestamp for all conversations? */} + {/* Last message {convertTime(conversation.updated_at)} */} + + ); + + const controlCheckBox = ( + handleCheckboxChange(conversation.id)} + checked={isChecked} + /> + ); + + return ( + + {selectActive ? ( + + ) : ( + + {/* body1 Typography is automatically applied in label above, added here to match for spacing */} + {itemText} + + )} + + ); + }); + }; + + const cancelSelect = () => { + setSelectActive(false); + setSelectAll(false); + setCheckedItems({}); + }; + + const deleteSelected = () => { + setSelectActive(false); + + let ids = []; + for (const [key, value] of Object.entries(checkedItems)) { + if (value === true) { + ids.push(key); + } + } + + if (ids.length > 0) { + //update current state + setHistoryList((prev) => + prev.filter((conversation) => !checkedItems[conversation.id]), + ); + dispatch( + deleteConversations({ user: name, conversationIds: ids, useCase: "" }), + ); + } + }; + + const handleSelectAll = () => { + const newSelectAll = !selectAll; + setSelectAll(newSelectAll); + + // Add all items' checked state + const updatedCheckedItems: Record = {}; + historyList.forEach((conversation) => { + updatedCheckedItems[conversation.id] = newSelectAll; + }); + + setCheckedItems(updatedCheckedItems); + }; + + const handleSearch = (value: string) => { + const filteredList = shared ? sharedConversations : conversations; + const searchResults = filteredList.filter((conversation: Conversation) => + conversation.first_query?.toLowerCase().includes(value.toLowerCase()), + ); + setHistoryList( + value ? searchResults : shared ? sharedConversations : conversations, + ); + }; + + return ( +
+ + +
+ + You have {historyList.length} previous chat + {historyList.length > 1 && "s"} + + + {historyList.length > 0 && ( +
+ {selectActive ? ( + handleSelectAll()}> + Select All + + ) : ( + setSelectActive(true)}> + Select + + )} + + {selectActive && ( + <> + cancelSelect()}>Cancel + deleteSelected()}> + Delete Selected + + + )} +
+ )} +
+ + {displayHistory()} +
+ ); +}; + +export default HistoryView; diff --git a/app-frontend/react/src/pages/Home/Home.module.scss b/app-frontend/react/src/pages/Home/Home.module.scss new file mode 100644 index 0000000..b4fd3df --- /dev/null +++ b/app-frontend/react/src/pages/Home/Home.module.scss @@ -0,0 +1,39 @@ +.homeView { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100%; + padding: calc(var(--header-gutter) * 2); + + .title { + text-align: center; + + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + .buttonRow { + margin-top: var(--vertical-spacer); + justify-content: center; + } + + .promptWrapper { + width: 100%; + max-width: 775px; + } + + .inputContainer { + width: 100%; + max-width: 800px; + margin-top: var(--vertical-spacer); + } + + .disclaimer { + width: 100%; + max-width: 600px; + margin-top: var(--vertical-spacer); + font-size: 14px; + } +} diff --git a/app-frontend/react/src/pages/Home/Home.tsx b/app-frontend/react/src/pages/Home/Home.tsx new file mode 100644 index 0000000..cd79826 --- /dev/null +++ b/app-frontend/react/src/pages/Home/Home.tsx @@ -0,0 +1,111 @@ +import { Button, Typography, Grid2, styled } from "@mui/material"; +// import { AtomIcon, AtomAnimation } from "@icons/Atom"; +import PrimaryInput from "@components/PrimaryInput/PrimaryInput"; +import config from "@root/config"; +import PromptSettings from "@components/PromptSettings/PromptSettings"; +import { UI_SELECTION } from "@root/config"; +import styles from "./Home.module.scss"; + +import { useNavigate } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "@redux/store"; +// import { userSelector } from "@redux/User/userSlice"; +import { + conversationSelector, + setType, + newConversation, +} from "@redux/Conversation/ConversationSlice"; +import { useEffect } from "react"; + +interface InitialStateProps { + initialMessage: string; +} + +const HomeButton = styled(Button)(({ theme }) => ({ + ...theme.customStyles.homeButtons, +})); + +const HomeTitle = styled(Typography)(({ theme }) => ({ + ...theme.customStyles.homeTitle, +})); + +const Home = () => { + // const { disclaimer } = config; + const enabledUI = UI_SELECTION + ? UI_SELECTION.split(",").map((item) => item.trim()) + : ["chat", "summary", "code"]; + + console.log("Enabled UI:", enabledUI); + + const { type, types, token, model, temperature } = + useAppSelector(conversationSelector); + const dispatch = useAppDispatch(); + + // const { name } = useAppSelector(userSelector); + + const navigate = useNavigate(); + + const handleSendMessage = async (messageContent: string) => { + const initialState: InitialStateProps = { + initialMessage: messageContent, + }; + + navigate(`/${type}/new`, { state: initialState }); + }; + + const handleTypeChange = (updateType: string) => { + dispatch(setType(updateType)); + }; + + useEffect(() => { + // clean up and reset. Can happen on going home from history/upload convo + // if convo is missing one of these + if (!model || !token || !temperature) { + dispatch(newConversation(true)); + } + }, []); + + return ( +
+ {/* */} + {/* */} + + + Hi, {config.tagline} + + + + {types.map((interactionType, index) => ( + enabledUI.includes(interactionType.key) && + ( + handleTypeChange(interactionType.key)} + aria-selected={type === interactionType.key} + startIcon={ + + } + variant="contained" + > + {interactionType.name} + + ) + ))} + + +
+ +
+ +
+ +
+ + {/*
*/} +
+ ); +}; + +export default Home; diff --git a/app-frontend/react/src/redux/Conversation/Conversation.ts b/app-frontend/react/src/redux/Conversation/Conversation.ts index 96ef58e..0714533 100644 --- a/app-frontend/react/src/redux/Conversation/Conversation.ts +++ b/app-frontend/react/src/redux/Conversation/Conversation.ts @@ -1,14 +1,52 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 +export interface UseCase { + use_case: string; + display_name: string; + access_level: string; +} + +export interface Model { + displayName: string; + endpoint?: string; + maxToken: number; + minToken: number; + model_name: string; + types: string[]; +} + export type ConversationRequest = { conversationId: string; userPrompt: Message; - messages: Partial[]; + messages: Message[]; + model: string; + temperature: number; + token: number; + files?: any[]; + time?: string; + type: string; +}; + +export type CodeRequest = { + conversationId: string; + userPrompt: Message; + messages: any[]; + model: string; + type: string; + token?: number; + temperature?: number; +}; + +export type SummaryFaqRequest = { + conversationId: string; + userPrompt: Message; + messages: Message[] | string; + files?: any[]; model: string; - maxTokens: number; temperature: number; - // setIsInThinkMode: (isInThinkMode: boolean) => void; + token: number; + type: string; }; export enum MessageRole { @@ -18,28 +56,57 @@ export enum MessageRole { } export interface Message { + message_id?: string; role: MessageRole; content: string; - time: number; - agentSteps?: AgentStep[]; // Optional, only for assistant messages + time?: string; } -export interface Conversation { - conversationId: string; - title?: string; - Messages: Message[]; +export interface ChatMessageProps { + message: Message; + pending?: boolean; } -export interface AgentStep { - tool: string; - content: any[]; - source: string[]; +export interface Conversation { + id: string; + first_query?: string; } +export type file = { + name: string; + id: string; + type: string; + parent: string; +}; + export interface ConversationReducer { selectedConversationId: string; conversations: Conversation[]; + sharedConversations: Conversation[]; + selectedConversationHistory: Message[]; onGoingResult: string; - fileDataSources: any; - isAgent: boolean; -} \ No newline at end of file + isPending: boolean; + filesInDataSource: file[]; + dataSourceUrlStatus: string; + + useCase: string; + useCases: UseCase[]; + model: string; + models: Model[]; + type: string; + types: any[]; + systemPrompt: string; + minToken: number; + maxToken: number; + token: number; + minTemperature: number; + maxTemperature: number; + temperature: number; + sourceType: string; + sourceLinks: string[]; + sourceFiles: any[]; + + abortController: AbortController | null; + + uploadInProgress: boolean; +} diff --git a/app-frontend/react/src/redux/Conversation/ConversationSlice.ts b/app-frontend/react/src/redux/Conversation/ConversationSlice.ts index f695045..51748cf 100644 --- a/app-frontend/react/src/redux/Conversation/ConversationSlice.ts +++ b/app-frontend/react/src/redux/Conversation/ConversationSlice.ts @@ -1,37 +1,105 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -import { RootState, store } from "../store"; +import { RootState, store } from "@redux/store"; import { fetchEventSource } from "@microsoft/fetch-event-source"; -import { Message, MessageRole, ConversationReducer, ConversationRequest } from "./Conversation"; -import { getCurrentTimeStamp, uuidv4 } from "../../common/util"; -import { createAsyncThunkWrapper } from "../thunkUtil"; -import client from "../../common/client"; -import { notifications } from "@mantine/notifications"; -import { CHAT_QNA_URL, DATA_PREP_URL } from "../../config"; -// import { useState } from 'react'; - -export interface FileDataSource { - id: string; - sources: string[]; - type: 'Files' | 'URLs'; - status: 'pending' | 'uploading' | 'uploaded' | 'failed'; - startTime: number; -} - -export interface AgentStep { - tool: string; - content: any[]; - source: string[]; -} +import { + Message, + MessageRole, + ConversationReducer, + ConversationRequest, + Conversation, + Model, + UseCase, + CodeRequest, + SummaryFaqRequest, +} from "./Conversation"; +import { getCurrentTimeStamp } from "@utils/utils"; +import { createAsyncThunkWrapper } from "@redux/thunkUtil"; +import axios from "axios"; + +import config, { + CHAT_QNA_URL, + DATA_PREP_URL, + DATA_PREP_GET_URL, + DATA_PREP_DELETE_URL, + CHAT_HISTORY_CREATE, + CHAT_HISTORY_GET, + CHAT_HISTORY_DELETE, + CODE_GEN_URL, + DOC_SUM_URL, + // FAQ_GEN_URL, +} from "@root/config"; +import { NotificationSeverity, notify } from "@components/Notification/Notification"; +import { ChatBubbleOutline, CodeOutlined, Description, QuizOutlined } from "@mui/icons-material"; +// import { data } from "react-router-dom"; + +const urlMap: any = { + summary: DOC_SUM_URL, + // faq: FAQ_GEN_URL, + chat: CHAT_QNA_URL, + code: CODE_GEN_URL, +}; + +const interactionTypes = [ + { + key: "chat", + name: "Chat Q&A", + icon: ChatBubbleOutline, + color: "#0ACA00", + }, + { + key: "summary", + name: "Summarize Content", + icon: Description, + color: "#FF4FFC", + }, + { + key: "code", + name: "Generate Code", + icon: CodeOutlined, + color: "#489BEA", + }, + // TODO: Enable file upload support for faqgen endpoint similar to summary + // { + // key: 'faq', + // name: 'Generate FAQ', + // icon: QuizOutlined, + // color: '#9D00FF' + // }, +]; const initialState: ConversationReducer = { conversations: [], + sharedConversations: [], selectedConversationId: "", + selectedConversationHistory: [], onGoingResult: "", - fileDataSources: [] as FileDataSource[], - isAgent: false, + isPending: false, + filesInDataSource: [], + dataSourceUrlStatus: "", + + useCase: "", + useCases: [], + model: "", + models: [], + type: "chat", + types: interactionTypes, + systemPrompt: config.defaultChatPrompt, + minToken: 100, + maxToken: 1000, + token: 100, + minTemperature: 0, + maxTemperature: 1, + temperature: 0.4, + sourceType: "documents", + sourceLinks: [], + sourceFiles: [], + + abortController: null, + + uploadInProgress: false, }; export const ConversationSlice = createSlice({ @@ -42,192 +110,549 @@ export const ConversationSlice = createSlice({ state.conversations = []; state.selectedConversationId = ""; state.onGoingResult = ""; - state.isAgent = false; + state.selectedConversationHistory = []; + state.filesInDataSource = []; + }, + setIsPending: (state, action: PayloadAction) => { + state.isPending = action.payload; }, setOnGoingResult: (state, action: PayloadAction) => { state.onGoingResult = action.payload; }, addMessageToMessages: (state, action: PayloadAction) => { - const selectedConversation = state.conversations.find((x) => x.conversationId === state.selectedConversationId); - selectedConversation?.Messages?.push(action.payload); + state.selectedConversationHistory.push(action.payload); }, - newConversation: (state) => { + newConversation: (state, action: PayloadAction) => { state.selectedConversationId = ""; state.onGoingResult = ""; - state.isAgent = false; + state.selectedConversationHistory = []; + + // full reset if true + if (action.payload) { + (state.sourceLinks = []), (state.sourceFiles = []); + + // in case of upload / history conversation that clears model name, we want to reset to defaults + const currentType = state.type; + if (currentType) { + const approvedModel = state.models.find((item: Model) => item.types.includes(currentType)); + if (approvedModel) { + state.model = approvedModel.model_name; + state.token = approvedModel.minToken; + state.temperature = 0.4; + } + } + } }, - createNewConversation: (state, action: PayloadAction<{ title: string; id: string; message: Message }>) => { - state.conversations.push({ - title: action.payload.title, - conversationId: action.payload.id, - Messages: [action.payload.message], - }); + updatePromptSettings: (state, action: PayloadAction) => { + state.model = action.payload.model; + state.token = action.payload.token; + state.temperature = action.payload.temperature; + state.type = action.payload.type; }, setSelectedConversationId: (state, action: PayloadAction) => { state.selectedConversationId = action.payload; }, - addFileDataSource: (state, action: PayloadAction<{ id: string; source: string[]; type: 'Files' | 'URLs'; startTime: number }>) => { - state.fileDataSources.push({ - id: action.payload.id, - source: action.payload.source, - type: action.payload.type, - startTime: action.payload.startTime, - status: 'pending', - }); + setSelectedConversationHistory: (state, action: PayloadAction) => { + state.selectedConversationHistory = action.payload; + }, + setTemperature: (state, action: PayloadAction) => { + state.temperature = action.payload; + }, + setToken: (state, action: PayloadAction) => { + state.token = action.payload; + }, + setModel: (state, action: PayloadAction) => { + state.model = action.payload.model_name; + state.maxToken = action.payload.maxToken; + state.minToken = action.payload.minToken; }, - clearFileDataSources: (state) => { - state.fileDataSources = []; + setModelName: (state, action: PayloadAction) => { + state.model = action.payload; }, - updateFileDataSourceStatus: (state, action: PayloadAction<{ id: string; status: 'pending' | 'uploading' | 'uploaded' | 'failed' }>) => { - const fileDataSource = state.fileDataSources.find((item: FileDataSource) => item.id === action.payload.id); - if (fileDataSource) { - fileDataSource.status = action.payload.status; + setModels: (state, action: PayloadAction<[]>) => { + state.models = action.payload; + }, + setUseCase: (state, action: PayloadAction) => { + state.useCase = action.payload; + }, + setUseCases: (state, action: PayloadAction<[]>) => { + state.useCases = action.payload; + }, + setType: (state, action: PayloadAction) => { + state.type = action.payload; + + switch (action.payload) { + case "summary": + case "faq": + state.systemPrompt = ""; + state.sourceType = "documents"; + break; + case "chat": + case "code": + state.systemPrompt = config.defaultChatPrompt; + state.sourceFiles = []; + state.sourceLinks = []; + break; } + + let firstModel = state.models.find((model: Model) => model.types.includes(action.payload)); + state.model = firstModel?.model_name || state.models[0].model_name; + }, + setUploadInProgress: (state, action: PayloadAction) => { + state.uploadInProgress = action.payload; + }, + setSourceLinks: (state, action: PayloadAction) => { + state.sourceLinks = action.payload; + }, + setSourceFiles: (state, action: PayloadAction) => { + state.sourceFiles = action.payload; + }, + setSourceType: (state, action: PayloadAction) => { + state.sourceType = action.payload; + }, + setSystemPrompt: (state, action: PayloadAction) => { + state.systemPrompt = action.payload; + }, + setAbortController: (state, action: PayloadAction) => { + state.abortController = action.payload; + }, + abortStream: (state) => { + if (state.abortController) state.abortController.abort(); + + const m: Message = { + role: MessageRole.Assistant, + content: state.onGoingResult, + time: getCurrentTimeStamp().toString(), + }; + + // add last message before ending + state.selectedConversationHistory.push(m); + state.onGoingResult = ""; + state.abortController = null; }, - setIsAgent: (state, action: PayloadAction) => { - state.isAgent = action.payload; + setDataSourceUrlStatus: (state, action: PayloadAction) => { + state.dataSourceUrlStatus = action.payload; + }, + uploadChat: (state, action: PayloadAction) => { + state.selectedConversationHistory = action.payload.messages; + state.model = action.payload.model; + state.token = action.payload.token; + state.temperature = action.payload.temperature; + state.type = action.payload.type; + state.sourceFiles = []; // only chat can be uploaded, empty if set + state.sourceLinks = []; // only chat can be uploaded, empty if set }, }, extraReducers(builder) { builder.addCase(uploadFile.fulfilled, () => { - notifications.update({ - id: "upload-file", - message: "File Uploaded Successfully", - loading: false, - autoClose: 3000, - }); + notify("File Uploaded Successfully", NotificationSeverity.SUCCESS); }); builder.addCase(uploadFile.rejected, () => { - notifications.update({ - color: "red", - id: "upload-file", - message: "Failed to Upload file", - loading: false, - }); + notify("Failed to Upload file", NotificationSeverity.ERROR); }); - builder.addCase(submitDataSourceURL.fulfilled, () => { - notifications.show({ - message: "Submitted Successfully", - }); + builder.addCase(submitDataSourceURL.fulfilled, (state) => { + notify("Submitted Successfully", NotificationSeverity.SUCCESS); + state.dataSourceUrlStatus = ""; // watching for pending only on front }); - builder.addCase(submitDataSourceURL.rejected, () => { - notifications.show({ - color: "red", - message: "Submit Failed", - }); + builder.addCase(submitDataSourceURL.rejected, (state) => { + notify("Submit Failed", NotificationSeverity.ERROR); + state.dataSourceUrlStatus = ""; // watching for pending only on front + }); + builder.addCase(deleteConversation.rejected, () => { + notify("Failed to Delete Conversation", NotificationSeverity.ERROR); + }); + builder.addCase(getAllConversations.fulfilled, (state, action) => { + state.conversations = action.payload; + }); + builder.addCase(getConversationHistory.fulfilled, (state, action) => { + state.selectedConversationHistory = action.payload; + }); + builder.addCase(saveConversationtoDatabase.fulfilled, (state, action) => { + if (state.selectedConversationId == "") { + state.selectedConversationId = action.payload; + state.conversations.push({ + id: action.payload, + first_query: state.selectedConversationHistory[1].content, + }); + window.history.pushState({}, "", `/chat/${action.payload}`); + } + }); + builder.addCase(getAllFilesInDataSource.fulfilled, (state, action) => { + state.filesInDataSource = action.payload; }); }, }); +export const getSupportedUseCases = createAsyncThunkWrapper( + "public/usecase_configs.json", + async (_: void, { getState }) => { + const response = await axios.get("/usecase_configs.json"); + store.dispatch(setUseCases(response.data)); + + // @ts-ignore + const state: RootState = getState(); + const userAccess = state.userReducer.role; + const currentUseCase = state.conversationReducer.useCase; + + // setDefault use case if not stored / already set by localStorage + if (!currentUseCase) { + const approvedAccess = response.data.find((item: UseCase) => item.access_level === userAccess); + if (approvedAccess) store.dispatch(setUseCase(approvedAccess)); + } + + return response.data; + }, +); + +export const getSupportedModels = createAsyncThunkWrapper( + "public/model_configs.json", + async (_: void, { getState }) => { + const response = await axios.get("/model_configs.json"); + store.dispatch(setModels(response.data)); + + // @ts-ignore + const state: RootState = getState(); + const currentModel = state.conversationReducer.model; + const currentType = state.conversationReducer.type; + + // setDefault use case if not stored / already set by localStorage + // TODO: revisit if type also gets stored and not defaulted on state + if (!currentModel && currentType) { + const approvedModel = response.data.find((item: Model) => item.types.includes(currentType)); + if (approvedModel) store.dispatch(setModel(approvedModel)); + } + + return response.data; + }, +); + +export const getAllConversations = createAsyncThunkWrapper( + "conversation/getAllConversations", + // async ({ user, useCase }: { user: string; useCase: string }, {}) => { + async ({ user }: { user: string; }, {}) => { + + //TODO: Add useCase + const response = await axios.post(CHAT_HISTORY_GET, { + user, + }); + + console.log("getAllConversations response", response.data); + + return response.data.reverse(); + }, +); + +export const getConversationHistory = createAsyncThunkWrapper( + "conversation/getConversationHistory", + async ({ user, conversationId }: { user: string; conversationId: string }, {}) => { + const response = await axios.post(CHAT_HISTORY_GET, { + user, + id: conversationId, + }); + console.log("getAllConversations response", response.data); + + + // update settings for response settings modal + store.dispatch( + updatePromptSettings({ + model: response.data.model, + token: response.data.max_tokens, + temperature: response.data.temperature, + type: response.data.request_type, + }), + ); + + return response.data.messages; + }, +); + export const submitDataSourceURL = createAsyncThunkWrapper( "conversation/submitDataSourceURL", async ({ link_list }: { link_list: string[] }, { dispatch }) => { - const id = uuidv4(); - dispatch(updateFileDataSourceStatus({ id, status: 'uploading' })); - - try { - const body = new FormData(); - body.append("link_list", JSON.stringify(link_list)); - const response = await client.post(`${DATA_PREP_URL}/ingest`, body); - return response.data; - } catch (error) { - console.log("error", error); - throw error; - } + dispatch(setDataSourceUrlStatus("pending")); + const body = new FormData(); + body.append("link_list", JSON.stringify(link_list)); + // body.append("parent", "appData"); // TODO: this did not work, in an attempt to sort data types + const response = await axios.post(DATA_PREP_URL, body); + dispatch(getAllFilesInDataSource({ knowledgeBaseId: "default" })); + return response.data; }, ); -export const uploadFile = createAsyncThunkWrapper("conversation/uploadFile", async ({ file }: { file: File }) => { - try { +export const getAllFilesInDataSource = createAsyncThunkWrapper( + "conversation/getAllFilesInDataSource", + async ({ knowledgeBaseId }: { knowledgeBaseId: string }, {}) => { + const body = { + knowledge_base_id: knowledgeBaseId, + }; + const response = await axios.post(DATA_PREP_GET_URL, body); + return response.data; + }, +); + +export const uploadFile = createAsyncThunkWrapper( + "conversation/uploadFile", + async ({ file }: { file: File }, { dispatch }) => { const body = new FormData(); body.append("files", file); + const response = await axios.post(DATA_PREP_URL, body); + dispatch(getAllFilesInDataSource({ knowledgeBaseId: "default" })); + return response.data; + }, +); + +export const deleteMultipleInDataSource = createAsyncThunkWrapper( + "conversation/deleteConversations", + async ({ files }: { files: string[] }, { dispatch }) => { + const promises = files.map((file) => + axios + .post(DATA_PREP_DELETE_URL, { + file_path: file.split("_")[1], + }) + .then((response) => { + return response.data; + }) + .catch((err) => { + notify("Error deleting file", NotificationSeverity.ERROR); + console.error(`Error deleting file`, file, err); + }), + ); + + await Promise.all(promises) + .then(() => { + notify("Files deleted successfully", NotificationSeverity.SUCCESS); + }) + .catch((err) => { + notify("Error deleting on or more of your files", NotificationSeverity.ERROR); + console.error("Error deleting on or more of your files", err); + }) + .finally(() => { + dispatch(getAllFilesInDataSource({ knowledgeBaseId: "default" })); + }); + }, +); - notifications.show({ - id: "upload-file", - message: "uploading File", - loading: true, +export const deleteInDataSource = createAsyncThunkWrapper( + "conversation/deleteInDataSource", + async ({ file }: { file: any }, { dispatch }) => { + const response = await axios.post(DATA_PREP_DELETE_URL, { + file_path: file, }); - const response = await client.post(`${DATA_PREP_URL}/ingest`, body); + dispatch(getAllFilesInDataSource({ knowledgeBaseId: "default" })); return response.data; - } catch (error) { - throw error; - } -}); + }, +); -export const { - logout, - setOnGoingResult, - newConversation, - addMessageToMessages, - setSelectedConversationId, - createNewConversation, - addFileDataSource, - updateFileDataSourceStatus, - clearFileDataSources, - setIsAgent, -} = ConversationSlice.actions; +export const saveConversationtoDatabase = createAsyncThunkWrapper( + "conversation/saveConversationtoDatabase", + async ({ conversation }: { conversation: Conversation }, { dispatch, getState }) => { + // @ts-ignore + const state: RootState = getState(); + const selectedConversationHistory = state.conversationReducer.selectedConversationHistory; + + //TODO: if we end up with a systemPrompt for code change this + const firstMessageIndex = state.conversationReducer.type === "code" ? 0 : 1; + + const response = await axios.post(CHAT_HISTORY_CREATE, { + data: { + user: state.userReducer.name, + messages: selectedConversationHistory, + time: getCurrentTimeStamp().toString(), + model: state.conversationReducer.model, + temperature: state.conversationReducer.temperature, + max_tokens: state.conversationReducer.token, + request_type: state.conversationReducer.type, + }, + id: conversation.id == "" ? null : conversation.id, + first_query: selectedConversationHistory[firstMessageIndex].content, + }); -export const conversationSelector = (state: RootState) => state.conversationReducer; -export const fileDataSourcesSelector = (state: RootState) => state.conversationReducer.fileDataSources; -export const isAgentSelector = (state: RootState) => state.conversationReducer.isAgent; + dispatch( + getAllConversations({ + user: state.userReducer.name, + // useCase: state.conversationReducer.useCase, + }), + ); + return response.data; + }, +); -export default ConversationSlice.reducer; +export const deleteConversations = createAsyncThunkWrapper( + "conversation/deleteConversations", + async ( + { user, conversationIds, useCase }: { user: string; conversationIds: string[]; useCase: string }, + { dispatch }, + ) => { + const promises = conversationIds.map((id) => + axios + .post(CHAT_HISTORY_DELETE, { + user, + id: id, + }) + .then((response) => { + return response.data; + }) + .catch((err) => { + notify("Error deleting conversation", NotificationSeverity.ERROR); + console.error(`Error deleting conversation ${id}`, err); + }), + ); + + await Promise.all(promises) + .then(() => { + notify("Conversations deleted successfully", NotificationSeverity.SUCCESS); + }) + .catch((err) => { + notify("Error deleting on or more of your conversations", NotificationSeverity.ERROR); + console.error("Error deleting on or more of your conversations", err); + }) + .finally(() => { + // dispatch(getAllConversations({ user, useCase })); + dispatch(getAllConversations({ user})); + + }); + }, +); + +export const deleteConversation = createAsyncThunkWrapper( + "conversation/delete", + async ( + { user, conversationId, useCase }: { user: string; conversationId: string; useCase: string }, + { dispatch }, + ) => { + const response = await axios.post(CHAT_HISTORY_DELETE, { + user, + id: conversationId, + }); -// let source: string[] = []; -// let content: any[] = []; -// let currentTool: string = ""; -let isAgent: boolean = false; -let currentAgentSteps: AgentStep[] = []; // Temporary storage for steps during streaming + dispatch(newConversation(false)); + // dispatch(getAllConversations({ user, useCase })); + dispatch(getAllConversations({ user })); + + return response.data; + }, +); export const doConversation = (conversationRequest: ConversationRequest) => { - const { conversationId, userPrompt, messages, model, maxTokens, temperature } = conversationRequest; - // const [isInThink, setIsInThink] = useState(false); - if (!conversationId) { - const id = uuidv4(); - store.dispatch( - createNewConversation({ - title: userPrompt.content, - id, - message: userPrompt, - }), - ); - store.dispatch(setSelectedConversationId(id)); - } else { - store.dispatch(addMessageToMessages(userPrompt)); - } + store.dispatch(setIsPending(true)); + + const { conversationId, userPrompt, messages, model, token, temperature, type } = conversationRequest; - const userPromptWithoutTime = { + // TODO: MAYBE... check first message if 'system' already exists... on dev during page edits the + // hot module reloads and instantly adds more system messages to the total messages + if (messages.length === 1) store.dispatch(addMessageToMessages(messages[0])); // do not re-add system prompt + store.dispatch(addMessageToMessages(userPrompt)); + + const userPromptWithTime = { role: userPrompt.role, content: userPrompt.content, + time: getCurrentTimeStamp().toString(), }; + const body = { - messages: [...messages, userPromptWithoutTime], + messages: [...messages, userPromptWithTime], model: model, - max_tokens: maxTokens, + max_tokens: token, temperature: temperature, stream: true, + // thread_id: "123344", // if conversationId is empty, it will be created + }; + + eventStream(type, body, conversationId); +}; + + +export const doSummaryFaq = (summaryFaqRequest: SummaryFaqRequest) => { + store.dispatch(setIsPending(true)); + + const { conversationId, model, token, temperature, type, messages, files, userPrompt } = summaryFaqRequest; + + const postWithFiles = files && files.length > 0; + console.log ("files", files) + const allowedFileTypes = { + audio: ["audio/mpeg", "audio/wav", "audio/ogg"], + video: ["video/mp4", "video/webm", "video/avi"], + documents: ["application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + txt: ["text/plain"], }; - let result = ""; // Accumulates the final answer - let thinkBuffer = ""; // Accumulates data for think blocks - let postThinkBuffer = ""; // Accumulates plain text after last - let isInThink = false; // Tracks if we're inside a block - // setIsInThinkMode(false); // Reset the think mode state - currentAgentSteps = []; // Reset steps for this message - isAgent = false; // Tracks if this is an agent message (set once, never reset) - let isMessageDispatched = false; // Tracks if the final message has been dispatched + + + const body: any = {}; + const formData = new FormData(); + + store.dispatch(addMessageToMessages(userPrompt)); + + if (postWithFiles) { + formData.append("messages", ""); + formData.append("model", model); + formData.append("max_tokens", token.toString()); + formData.append("temperature", temperature.toString()); + + files.forEach((file) => { + console.log("file", file); + console.log("file type", file.file.type); + console.log ("is audio", allowedFileTypes.audio.includes(file.file.type)); + const fileType = file.file.type; + allowedFileTypes.audio.includes(fileType)? formData.append("type", "audio"): + allowedFileTypes.video.includes(fileType) ? formData.append("type", "video") : + formData.append("type", "text") + }); + files.forEach((file) => { + formData.append("files", file.file); + }); + console.log("FormData contents:"); + Array.from(formData.entries()).forEach(([key, value]) => { + console.log(`${key}: ${value instanceof File ? value.name : value}`); + }); + console.log ("urlMap[type]", urlMap[type]); + formDataEventStream(urlMap[type], formData); + } else { + body.messages = messages; + body.model = model; + (body.max_tokens = token), (body.temperature = temperature); + body.type = "text"; + + eventStream(type, body, conversationId); + } +}; + +export const doCodeGen = (codeRequest: CodeRequest) => { + store.dispatch(setIsPending(true)); + + const { conversationId, userPrompt, model, token, temperature, type } = codeRequest; + + store.dispatch(addMessageToMessages(userPrompt)); + + const body = { + messages: userPrompt.content, + model: model, //'meta-llama/Llama-3.3-70B-Instruct', + max_tokens: token, + temperature: temperature, + }; + + eventStream(type, body, conversationId); +}; + +const eventStream = (type: string, body: any, conversationId: string = "") => { + const abortController = new AbortController(); + store.dispatch(setAbortController(abortController)); + const signal = abortController.signal; + + let result = ""; try { - console.log("CHAT_QNA_URL", CHAT_QNA_URL); - fetchEventSource(CHAT_QNA_URL, { + fetchEventSource(urlMap[type], { method: "POST", + body: JSON.stringify(body), headers: { "Content-Type": "application/json", }, - body: JSON.stringify(body), + signal, openWhenHidden: true, async onopen(response) { if (response.ok) { + store.dispatch(setIsPending(false)); return; } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { const e = await response.json(); @@ -235,246 +660,213 @@ export const doConversation = (conversationRequest: ConversationRequest) => { throw Error(e.error.message); } else { console.log("error", response); + notify("Error in opening stream", NotificationSeverity.ERROR); } }, onmessage(msg) { - if (msg?.data === "[DONE]") { - // Stream is done, finalize the message - if (isAgent && thinkBuffer) { - processThinkContent(thinkBuffer); - } - if (!isMessageDispatched) { - // Use postThinkBuffer as the final answer if present - if (postThinkBuffer.trim()) { - result = postThinkBuffer.trim(); + if (msg?.data != "[DONE]") { + // console.log("msg", msg.data); + try { + if (type === "code") { + const parsedData = JSON.parse(msg.data); + result += parsedData.choices[0].delta.content; + store.dispatch(setOnGoingResult(result)); } - store.dispatch(setOnGoingResult(result)); - store.dispatch( - addMessageToMessages({ - role: MessageRole.Assistant, - content: result, - time: getCurrentTimeStamp(), - agentSteps: isAgent ? [...currentAgentSteps] : [], - }), - ); - isMessageDispatched = true; - } - currentAgentSteps = []; // Clear steps for next message - postThinkBuffer = ""; - return; - } + if (type === "chat") { + let parsed = false; - const data = msg?.data || ""; + try { + const res = JSON.parse(msg.data); + const data = res.choices[0].delta.content; - // Handle think blocks and non-think content - if (data.includes("")) { - if (!isAgent) { - isAgent = true; - store.dispatch(setIsAgent(true)); - } - // Split on to handle content before it - const parts = data.split(""); - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (i === 0 && !isInThink && part) { - // Content before (non-think) - postThinkBuffer += part; - if (isAgent) { - store.dispatch(setOnGoingResult(postThinkBuffer)); - } else { - result += part; + result += data; store.dispatch(setOnGoingResult(result)); + parsed = true; + } catch (e) { + console.warn("JSON parsing failed", e); + } + + // Fallback if JSON wasn't parsed + if (!parsed) { + const match = msg.data.match(/b'([^']*)'/); + if (match && match[1] !== "") { + const extractedText = match[1]; + result += extractedText; + store.dispatch(setOnGoingResult(result)); + } } } else { - // Start or continue think block - isInThink = true; - // setIsInThinkMode(true); // Set think mode state - thinkBuffer += part; - // Check if part contains - if (part.includes("")) { - const [thinkContent, afterThink] = part.split("", 2); - thinkBuffer = thinkBuffer.substring(0, thinkBuffer.indexOf(part)) + thinkContent; - processThinkContent(thinkBuffer); - thinkBuffer = ""; - isInThink = false; - // setIsInThinkMode(false); // Reset think mode state - if (afterThink) { - // Handle content after as non-think - if (!afterThink.includes("")) { - postThinkBuffer += afterThink; - store.dispatch(setOnGoingResult(postThinkBuffer)); - } else { - thinkBuffer = afterThink; - isInThink = true; - // setIsInThinkMode(true); // Set think mode state + //text summary/faq for data: "ops string" + const res = JSON.parse(msg.data); // Parse valid JSON + const logs = res.ops; + logs.forEach((log: { op: string; path: string; value: string }) => { + if (log.op === "add") { + if ( + log.value !== "" && + log.path.endsWith("/streamed_output/-") && + log.path.length > "/streamed_output/-".length + ) { + result += log.value; + if (log.value) store.dispatch(setOnGoingResult(result)); } } - } - } - } - } else if (isInThink) { - // Accumulate within think block - thinkBuffer += data; - if (data.includes("")) { - const [thinkContent, afterThink] = data.split("", 2); - thinkBuffer = thinkBuffer.substring(0, thinkBuffer.lastIndexOf(data)) + thinkContent; - processThinkContent(thinkBuffer); - thinkBuffer = ""; - isInThink = false; - // setIsInThinkMode(false); // Reset think mode state - if (afterThink) { - // Handle content after - if (!afterThink.includes("")) { - postThinkBuffer += afterThink; - store.dispatch(setOnGoingResult(postThinkBuffer)); - } else { - thinkBuffer = afterThink; - isInThink = true; - // setIsInThinkMode(true); // Set think mode state - } + }); } - } - } else { - // Non-agent or post-think plain text - if (isAgent) { - postThinkBuffer += data; - store.dispatch(setOnGoingResult(postThinkBuffer)); - } else { - result += data; - store.dispatch(setOnGoingResult(result)); + } catch (e) { + console.log("something wrong in msg", e); + notify("Error in message response", NotificationSeverity.ERROR); + throw e; } } }, onerror(err) { console.log("error", err); store.dispatch(setOnGoingResult("")); + notify("Error streaming response", NotificationSeverity.ERROR); throw err; }, onclose() { - if (!isMessageDispatched && (result || postThinkBuffer || (isAgent && currentAgentSteps.length > 0))) { - // Use postThinkBuffer as the final answer if present - if (postThinkBuffer.trim()) { - result = postThinkBuffer.trim(); - } - store.dispatch(setOnGoingResult(result)); + const m: Message = { + role: MessageRole.Assistant, + content: result, + time: getCurrentTimeStamp().toString(), + }; + + store.dispatch(setOnGoingResult("")); + store.dispatch(setAbortController(null)); + store.dispatch(addMessageToMessages(m)); + + if (type === "chat") { store.dispatch( - addMessageToMessages({ - role: MessageRole.Assistant, - content: result, - time: getCurrentTimeStamp(), - agentSteps: isAgent ? [...currentAgentSteps] : [], + saveConversationtoDatabase({ + conversation: { + id: conversationId, + }, }), ); - isMessageDispatched = true; } - store.dispatch(setOnGoingResult("")); - currentAgentSteps = []; - postThinkBuffer = ""; }, }); } catch (err) { console.log(err); } +}; - // Helper function to process content within tags - function processThinkContent(content: string) { - content = content.trim(); - if (!content) return; - - const toolCallRegex = /TOOL CALL: (\{.*?\})/g; - const finalAnswerRegex = /FINAL ANSWER: (\{.*?\})/; - let stepContent: string[] = []; // Collect all reasoning for this think block - let tool: string = "reasoning"; // Default tool - let source: string[] = []; // Tool output - - // Split content by final answer (if present) - let remainingContent = content; - const finalAnswerMatch = content.match(finalAnswerRegex); - if (finalAnswerMatch) { - try { - const finalAnswer = JSON.parse(finalAnswerMatch[1].replace("FINAL ANSWER: ", "")); - if (finalAnswer.answer) { - result = finalAnswer.answer; - } - remainingContent = content.split(finalAnswerMatch[0])[0].trim(); // Content before FINAL ANSWER - tool = "final_answer"; - } catch (e) { - console.error("Error parsing final answer:", finalAnswerMatch[1], e); - } +const formDataEventStream = async (url: string, formData: any) => { + const abortController = new AbortController(); + store.dispatch(setAbortController(abortController)); + const signal = abortController.signal; + + let result = ""; + + try { + const response = await fetch(url, { + method: "POST", + body: formData, + signal, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); } - // Process tool calls within the remaining content - const toolMatches = remainingContent.match(toolCallRegex) || []; - let currentContent = remainingContent; + if (response && response.body) { + store.dispatch(setIsPending(false)); - if (toolMatches.length > 0) { - // Handle content before and after tool calls - toolMatches.forEach((toolCallStr) => { - const [beforeTool, afterTool] = currentContent.split(toolCallStr, 2); - if (beforeTool.trim()) { - stepContent.push(beforeTool.trim()); - } + const reader = response.body.getReader(); - try { - // Attempt to parse the tool call JSON - let toolCall; - try { - toolCall = JSON.parse(toolCallStr.replace("TOOL CALL: ", "")); - } catch (e) { - console.error("Error parsing tool call JSON, attempting recovery:", toolCallStr, e); - // Attempt to extract tool and content manually - const toolMatch = toolCallStr.match(/"tool":\s*"([^"]+)"/); - const contentMatch = toolCallStr.match(/"tool_content":\s*\["([^"]+)"\]/); - toolCall = { - tool: toolMatch ? toolMatch[1] : "unknown", - args: { - tool_content: contentMatch ? [contentMatch[1]] : [], - }, - }; - } + // Read the stream in chunks + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } - tool = toolCall.tool || tool; - source = toolCall.args?.tool_content || source; + // Process the chunk of data (e.g., convert to text) + const textChunk = new TextDecoder().decode(value).trim(); + + // sometimes double lines return + const lines = textChunk.split("\n"); + + for (let line of lines) { + if (line.startsWith("data:")) { + const jsonStr = line.replace(/^data:\s*/, ""); // Remove "data: " + + if (jsonStr !== "[DONE]") { + try { + // API Response for final output regularly returns incomplete JSON, + // due to final response containing source summary content and exceeding + // token limit in the response. We don't use it anyway so don't parse it. + if (!jsonStr.includes('"path":"/streamed_output/-"')) { + const res = JSON.parse(jsonStr); // Parse valid JSON + + const logs = res.ops; + logs.forEach((log: { op: string; path: string; value: string }) => { + if (log.op === "add") { + if ( + log.value !== "" && + log.path.endsWith("/streamed_output/-") && + log.path.length > "/streamed_output/-".length + ) { + result += log.value; + if (log.value) store.dispatch(setOnGoingResult(result)); + } + } + }); + } + } catch (error) { + console.warn("Error parsing JSON:", error, "Raw Data:", jsonStr); + } + } else { + const m: Message = { + role: MessageRole.Assistant, + content: result, + time: getCurrentTimeStamp().toString(), + }; - // Clean up afterTool to remove invalid JSON fragments - if (afterTool.trim()) { - // Remove any trailing malformed JSON (e.g., "Chinook?"}}) - const cleanAfterTool = afterTool.replace(/[\s\S]*?(\}\s*)$/, "").trim(); - if (cleanAfterTool) { - stepContent.push(cleanAfterTool); + store.dispatch(setOnGoingResult("")); + store.dispatch(addMessageToMessages(m)); + store.dispatch(setAbortController(null)); } } - - } catch (e) { - console.error("Failed to process tool call:", toolCallStr, e); - stepContent.push(`[Error parsing tool call: ${toolCallStr}]`); } - - currentContent = afterTool; - }); - } else { - // No tool calls, treat as reasoning - if (remainingContent.trim()) { - stepContent.push(remainingContent.trim()); } } - - // Add the step for this think block - if (stepContent.length > 0 || source.length > 0) { - currentAgentSteps.push({ - tool, - content: stepContent, - source, - }); - } - - // Update onGoingResult to trigger UI update with latest steps - if (isAgent) { - const latestContent = currentAgentSteps.flatMap(step => step.content).join(" "); - const latestSource = source.length > 0 ? source.join(" ") : ""; - store.dispatch(setOnGoingResult(latestContent + (latestSource ? " " + latestSource : "") + (postThinkBuffer ? " " + postThinkBuffer : ""))); + } catch (error: any) { + if (error.name === "AbortError") { + console.log("Fetch aborted successfully."); + } else { + console.error("Fetch error:", error); } } }; -export const getCurrentAgentSteps = () => currentAgentSteps; // Export for use in Conversation.tsx \ No newline at end of file +export const { + logout, + setOnGoingResult, + setIsPending, + newConversation, + updatePromptSettings, + addMessageToMessages, + setSelectedConversationId, + setSelectedConversationHistory, + setTemperature, + setToken, + setModel, + setModelName, + setModels, + setType, + setUploadInProgress, + setSourceLinks, + setSourceFiles, + setSourceType, + setUseCase, + setUseCases, + setSystemPrompt, + setAbortController, + abortStream, + setDataSourceUrlStatus, + uploadChat, +} = ConversationSlice.actions; +export const conversationSelector = (state: RootState) => state.conversationReducer; +export default ConversationSlice.reducer; diff --git a/app-frontend/react/src/redux/Prompt/PromptSlice.ts b/app-frontend/react/src/redux/Prompt/PromptSlice.ts new file mode 100644 index 0000000..e53e3e5 --- /dev/null +++ b/app-frontend/react/src/redux/Prompt/PromptSlice.ts @@ -0,0 +1,96 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createAsyncThunkWrapper } from "@redux/thunkUtil"; +import { RootState } from "@redux/store"; +import { PROMPT_MANAGER_CREATE, PROMPT_MANAGER_GET, PROMPT_MANAGER_DELETE } from "@root/config"; +import { NotificationSeverity, notify } from "@components/Notification/Notification"; +import axios from "axios"; + +type promptReducer = { + prompts: Prompt[]; +}; + +export type Prompt = { + id: string; + prompt_text: string; + user: string; + type: string; +}; + +const initialState: promptReducer = { + prompts: [], +}; + +export const PromptSlice = createSlice({ + name: "Prompts", + initialState, + reducers: { + clearPrompts: (state) => { + state.prompts = []; + }, + }, + extraReducers(builder) { + builder.addCase(getPrompts.fulfilled, (state, action: PayloadAction) => { + state.prompts = action.payload; + }); + builder.addCase(addPrompt.fulfilled, () => { + notify("Prompt added Successfully", NotificationSeverity.SUCCESS); + }); + builder.addCase(deletePrompt.fulfilled, () => { + notify("Prompt deleted Successfully", NotificationSeverity.SUCCESS); + }); + }, +}); + +export const { clearPrompts } = PromptSlice.actions; +export const promptSelector = (state: RootState) => state.promptReducer; +export default PromptSlice.reducer; + +export const getPrompts = createAsyncThunkWrapper("prompts/getPrompts", async (_: void, { getState }) => { + // @ts-ignore + const state: RootState = getState(); + const response = await axios.post(PROMPT_MANAGER_GET, { + user: state.userReducer.name, + }); + return response.data; +}); + +export const addPrompt = createAsyncThunkWrapper( + "prompts/addPrompt", + async ({ promptText }: { promptText: string }, { dispatch, getState }) => { + // @ts-ignore + const state: RootState = getState(); + const response = await axios.post(PROMPT_MANAGER_CREATE, { + prompt_text: promptText, + user: state.userReducer.name, + //TODO: Would be nice to support type to set prompts for each + // type: state.conversationReducer.type // TODO: this might be crashing chatqna endpoint? + }); + + dispatch(getPrompts()); + + return response.data; + }, +); + +//TODO delete prompt doesn't actually work, but responds 200 +export const deletePrompt = createAsyncThunkWrapper( + "prompts/deletePrompt", + async ({ promptId, promptText }: { promptId: string; promptText: string }, { dispatch, getState }) => { + // @ts-ignore + const state: RootState = getState(); + const user = state.userReducer.name; + + const response = await axios.post(PROMPT_MANAGER_DELETE, { + user: user, + prompt_id: promptId, + prompt_text: promptText, + }); + + dispatch(getPrompts()); + + return response.data; + }, +); diff --git a/app-frontend/react/src/redux/User/user.d.ts b/app-frontend/react/src/redux/User/user.d.ts index 69c4db4..25b2e6b 100644 --- a/app-frontend/react/src/redux/User/user.d.ts +++ b/app-frontend/react/src/redux/User/user.d.ts @@ -1,6 +1,8 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 export interface User { - name: string | null; + name: string; + isAuthenticated: boolean; + role: "Admin" | "User"; } diff --git a/app-frontend/react/src/redux/User/userSlice.ts b/app-frontend/react/src/redux/User/userSlice.ts index 48d22fe..8dd7d23 100644 --- a/app-frontend/react/src/redux/User/userSlice.ts +++ b/app-frontend/react/src/redux/User/userSlice.ts @@ -1,23 +1,27 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { RootState } from "../store"; +import { RootState } from "@redux/store"; import { User } from "./user"; const initialState: User = { - name: localStorage.getItem("user"), + name: "", + isAuthenticated: false, + role: "User", }; export const userSlice = createSlice({ - name: "user", + name: "init user", initialState, reducers: { - setUser: (state, action: PayloadAction) => { - state.name = action.payload; + setUser: (state, action: PayloadAction) => { + state.name = action.payload.name; + state.isAuthenticated = action.payload.isAuthenticated; + state.role = action.payload.role; }, removeUser: (state) => { - state.name = null; + state.name = ""; }, }, }); diff --git a/app-frontend/react/src/redux/store.ts b/app-frontend/react/src/redux/store.ts index 3a4e142..5de6ac7 100644 --- a/app-frontend/react/src/redux/store.ts +++ b/app-frontend/react/src/redux/store.ts @@ -1,64 +1,47 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + import { combineReducers, configureStore } from "@reduxjs/toolkit"; -import userReducer from "./User/userSlice"; -import conversationReducer from "./Conversation/ConversationSlice"; -// import sandboxReducer from "./Sandbox/SandboxSlice"; +import userReducer from "@redux/User/userSlice"; +import conversationReducer from "@redux/Conversation/ConversationSlice"; +import promptReducer from "@redux/Prompt/PromptSlice"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; -import { APP_UUID } from "../config"; - -function getBucketKey() { - const url = new URL(window.location.href); - const query = url.search; - return `${query}_${APP_UUID}`; -} - -function saveToLocalStorage(state: ReturnType) { - try { - const bucketKey = getBucketKey(); - const serialState = JSON.stringify(state); - localStorage.setItem(`reduxStore_${bucketKey}`, serialState); - } catch (e) { - console.warn("Could not save state to localStorage:", e); - } -} - -function loadFromLocalStorage() { - try { - const bucketKey = getBucketKey(); - const serialisedState = localStorage.getItem(`reduxStore_${bucketKey}`); - if (serialisedState === null) return undefined; - return JSON.parse(serialisedState); - } catch (e) { - console.warn("Could not load state from localStorage:", e); - return undefined; - } -} export const store = configureStore({ reducer: combineReducers({ userReducer, conversationReducer, - // sandboxReducer, + promptReducer, }), devTools: import.meta.env.PROD || true, - preloadedState: loadFromLocalStorage(), + // preloadedState: loadFromLocalStorage(), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, }), }); -// Remove Redux state for the specific bucket key -export function clearLocalStorageBucket() { - try { - const bucketKey = getBucketKey(); - localStorage.removeItem(`reduxStore_${bucketKey}`); - } catch (e) { - console.warn("Could not clear localStorage bucket:", e); - } -} - -store.subscribe(() => saveToLocalStorage(store.getState())); - +// function saveToLocalStorage(state: ReturnType) { +// try { +// const serialState = JSON.stringify(state); +// localStorage.setItem("reduxStore", serialState); +// } catch (e) { +// console.warn(e); +// } +// } + +// function loadFromLocalStorage() { +// try { +// const serialisedState = localStorage.getItem("reduxStore"); +// if (serialisedState === null) return undefined; +// return JSON.parse(serialisedState); +// } catch (e) { +// console.warn(e); +// return undefined; +// } +// } + +// store.subscribe(() => saveToLocalStorage(store.getState())); export default store; export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType; diff --git a/app-frontend/react/src/redux/thunkUtil.ts b/app-frontend/react/src/redux/thunkUtil.ts index 5df362f..8db3b30 100644 --- a/app-frontend/react/src/redux/thunkUtil.ts +++ b/app-frontend/react/src/redux/thunkUtil.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 import { createAsyncThunk, AsyncThunkPayloadCreator, AsyncThunk } from "@reduxjs/toolkit"; diff --git a/app-frontend/react/src/shared/ActionButtons.tsx b/app-frontend/react/src/shared/ActionButtons.tsx new file mode 100644 index 0000000..55abeed --- /dev/null +++ b/app-frontend/react/src/shared/ActionButtons.tsx @@ -0,0 +1,94 @@ +import { Button, styled } from "@mui/material"; + +const TextOnlyStyle = styled(Button)(({ theme }) => ({ + ...theme.customStyles.actionButtons.text, +})); + +const DeleteStyle = styled(Button)(({ theme }) => ({ + ...theme.customStyles.actionButtons.delete, +})); + +const SolidStyle = styled(Button)(({ theme }) => ({ + ...theme.customStyles.actionButtons.solid, +})); + +const OutlineStyle = styled(Button)(({ theme }) => ({ + ...theme.customStyles.actionButtons.outline, +})); + +type ButtonProps = { + onClick: (value: boolean) => void; + children: React.ReactNode | React.ReactNode[]; + disabled?: boolean; + className?: string; +}; + +const TextButton: React.FC = ({ + onClick, + children, + disabled = false, + className, +}) => { + return ( + onClick(true)} + className={className} + > + {children} + + ); +}; + +const DeleteButton: React.FC = ({ + onClick, + children, + disabled = false, + className, +}) => { + return ( + onClick(true)} + className={className} + > + {children} + + ); +}; + +const SolidButton: React.FC = ({ + onClick, + children, + disabled = false, + className, +}) => { + return ( + onClick(true)} + className={className} + > + {children} + + ); +}; + +const OutlineButton: React.FC = ({ + onClick, + children, + disabled = false, + className, +}) => { + return ( + onClick(true)} + className={className} + > + {children} + + ); +}; + +export { TextButton, DeleteButton, SolidButton, OutlineButton }; diff --git a/app-frontend/react/src/shared/ModalBox/Modal.module.scss b/app-frontend/react/src/shared/ModalBox/Modal.module.scss new file mode 100644 index 0000000..ae9b7d0 --- /dev/null +++ b/app-frontend/react/src/shared/ModalBox/Modal.module.scss @@ -0,0 +1,50 @@ +.modal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 400px; + width: 100%; + padding: 0; + min-width: 300px; + z-index: 9999; + + :global { + #modal-modal-title { + padding: 0.75rem 1rem; + font-weight: 600; + font-size: 0.8rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + #modal-modal-description { + padding: 1.5rem 1rem 1rem; + margin-top: -1rem; + + .MuiFormControlLabel-label, + .MuiTypography-root { + font-weight: 300; + font-size: 0.8rem; + margin-top: 0.5rem; + } + + .MuiBox-root { + align-items: flex-start; + } + + .MuiButton-root { + padding: 5px 10px; + + + .MuiButton-root { + margin-left: 0.5rem; + } + } + } + button { + padding: 0; + } + } +} diff --git a/app-frontend/react/src/shared/ModalBox/ModalBox.tsx b/app-frontend/react/src/shared/ModalBox/ModalBox.tsx new file mode 100644 index 0000000..0f3c9b9 --- /dev/null +++ b/app-frontend/react/src/shared/ModalBox/ModalBox.tsx @@ -0,0 +1,29 @@ +import { Modal, styled } from "@mui/material"; + +import styles from "./Modal.module.scss"; + +const StyledModalBox = styled("div")(({ theme }) => ({ + ...theme.customStyles.settingsModal, +})); + +const ModalBox: React.FC<{ + children: React.ReactNode; + open?: boolean; + onClose?: () => void; +}> = ({ children, open = true, onClose }) => { + let props: any = {}; + if (onClose) props.onClose = onClose; + + return ( + + {children} + + ); +}; + +export default ModalBox; diff --git a/app-frontend/react/src/styles/components/_context.scss b/app-frontend/react/src/styles/components/_context.scss deleted file mode 100644 index e69de29..0000000 diff --git a/app-frontend/react/src/styles/components/_sidebar.scss b/app-frontend/react/src/styles/components/_sidebar.scss deleted file mode 100644 index 23018ee..0000000 --- a/app-frontend/react/src/styles/components/_sidebar.scss +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -@import "../layout/flex"; - -@mixin sidebar { - @include flex(column, nowrap, flex-start, flex-start); -} diff --git a/app-frontend/react/src/styles/components/content.scss b/app-frontend/react/src/styles/components/content.scss deleted file mode 100644 index 9a230f2..0000000 --- a/app-frontend/react/src/styles/components/content.scss +++ /dev/null @@ -1,5 +0,0 @@ -@mixin textWrapEllipsis { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} diff --git a/app-frontend/react/src/styles/components/context.module.scss b/app-frontend/react/src/styles/components/context.module.scss deleted file mode 100644 index 17f37ba..0000000 --- a/app-frontend/react/src/styles/components/context.module.scss +++ /dev/null @@ -1,67 +0,0 @@ -@import "../layout/flex"; -@import "../components/content.scss"; - -.contextWrapper { - background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); - border-right: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); - width: 180px; - overflow-y: hidden; - overflow-x: hidden; - // overflow-y: auto; - - .contextTitle { - position: sticky; - top: 0; - font-family: - Greycliff CF, - var(--mantine-font-family); - margin-bottom: var(--mantine-spacing-xl); - background-color: var(--mantine-color-body); - padding: var(--mantine-spacing-md); - padding-top: 18px; - width: 100%; - height: 60px; - border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7)); - } - - .contextList { - height: 90vh; - // display: flex(); - - .contextListItem { - display: block; - text-decoration: none; - border-top-right-radius: var(--mantine-radius-md); - border-bottom-right-radius: var(--mantine-radius-md); - color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); - padding: 0 var(--mantine-spacing-md); - font-size: var(--mantine-font-size-sm); - margin-right: var(--mantine-spacing-md); - font-weight: 500; - height: 44px; - width: 100%; - line-height: 44px; - cursor: pointer; - - .contextItemName { - flex: 1 1 auto; - width: 130px; - @include textWrapEllipsis; - } - - &:hover { - background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); - color: light-dark(var(--mantine-color-dark), var(--mantine-color-light)); - } - - &[data-active] { - &, - &:hover { - border-left-color: var(--mantine-color-blue-filled); - background-color: var(--mantine-color-blue-filled); - color: var(--mantine-color-white); - } - } - } - } -} diff --git a/app-frontend/react/src/styles/layout/_basics.scss b/app-frontend/react/src/styles/layout/_basics.scss deleted file mode 100644 index d11b1ef..0000000 --- a/app-frontend/react/src/styles/layout/_basics.scss +++ /dev/null @@ -1,7 +0,0 @@ -@mixin absolutes { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} diff --git a/app-frontend/react/src/styles/layout/_flex.scss b/app-frontend/react/src/styles/layout/_flex.scss deleted file mode 100644 index 18d2ce8..0000000 --- a/app-frontend/react/src/styles/layout/_flex.scss +++ /dev/null @@ -1,6 +0,0 @@ -@mixin flex($direction: row, $wrap: nowrap, $alignItems: center, $justifyContent: center) { - display: flex; - flex-flow: $direction $wrap; - align-items: $alignItems; - justify-content: $justifyContent; -} diff --git a/app-frontend/react/src/styles/styles.scss b/app-frontend/react/src/styles/styles.scss deleted file mode 100644 index 8028d8a..0000000 --- a/app-frontend/react/src/styles/styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -@import "layout/flex"; -@import "layout/basics"; diff --git a/app-frontend/react/src/theme/theme.tsx b/app-frontend/react/src/theme/theme.tsx new file mode 100644 index 0000000..e79c64a --- /dev/null +++ b/app-frontend/react/src/theme/theme.tsx @@ -0,0 +1,456 @@ +import { createTheme } from "@mui/material/styles"; +import moonIcon from "@assets/icons/moon.svg"; +import sunIcon from "@assets/icons/sun.svg"; + +const lightBg = "#F2F3FF"; + +const lightGrey = "#1f2133"; + +const lightPurple = "#e3e5fd"; +const deepPurple = "#3D447F"; +const darkPurple = "#222647"; +const brightPurple = "#6b77db"; +const white60 = "#ffffff60"; + +export const themeCreator = (mode: "light" | "dark") => { + return createTheme({ + palette: { + mode: mode, // Default mode + primary: { + main: mode === "dark" ? "#ffffff" : "#ffffff", + contrastText: "#000000", + }, + secondary: { + main: deepPurple, + contrastText: "#ffffff", + }, + background: { + default: mode === "dark" ? "#090B1C" : lightBg, + paper: mode === "dark" ? "#161b22" : "#ffffff", + }, + text: { + primary: mode === "dark" ? "#c9d1d9" : "#000000", + secondary: mode === "dark" ? "#ffffff" : deepPurple, + }, + }, + typography: { + fontFamily: "Roboto, Arial, sans-serif", + h1: { + fontWeight: 700, + fontSize: "2rem", + lineHeight: 1.5, + color: mode === "dark" ? "#ffffff" : deepPurple, + }, + h2: { + fontWeight: 500, + fontSize: "1rem", + lineHeight: 1.4, + color: mode === "dark" ? "#ffffff" : deepPurple, + }, + body1: { + fontSize: "1rem", + fontWeight: 300, + lineHeight: 1.5, + color: mode === "dark" ? "#ffffff" : deepPurple, + }, + button: { + textTransform: "none", + fontWeight: 600, + }, + }, + components: { + MuiIconButton: { + styleOverrides: { + root: ({ theme }) => ({ + svg: { + fill: theme.customStyles.icon?.main, + }, + }), + }, + }, + + MuiCheckbox: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.customStyles.icon?.main, + "&.Mui-checked": { + color: theme.customStyles.icon?.main, + }, + }), + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundColor: mode === "dark" ? lightGrey : darkPurple, + }, + arrow: { + color: mode === "dark" ? lightGrey : darkPurple, + }, + }, + }, + }, + customStyles: { + header: { + backgroundColor: mode === "dark" ? "#090B1C" : "#228BE6", + boxShadow: mode === "dark" ? "none" : "0px 1px 24.1px 0px #4953D526", + borderBottom: mode === "dark" ? `1px solid ${deepPurple}7A` : "none", + }, + aside: { + main: mode === "dark" ? lightGrey : "#E5E7FE", + }, + customDivider: { + main: mode === "dark" ? white60 : deepPurple, + }, + user: { + main: mode === "dark" ? "#161b22" : "#E3E5FD", + }, + icon: { + main: mode === "dark" ? "#E5E7FE" : deepPurple, + }, + input: { + main: mode === "dark" ? "#ffffff" : "#ffffff", // background color + primary: mode === "dark" ? "#c9d1d9" : "#000000", + secondary: mode === "dark" ? "#ffffff" : "#6b7280", + }, + code: { + // title: mode === 'dark' ? '#2b2b2b' : '#2b2b2b', + primary: mode === "dark" ? "#5B5D74" : "#B6B9D4", + // text: mode === 'dark' ? '#ffffff' : '#ffffff', + // secondary: mode === 'dark' ? '#141415' : '#141415', + }, + gradientShadow: { + border: `1px solid ${mode === "dark" ? "#ffffff20" : deepPurple + "10"}`, + boxShadow: + mode === "dark" + ? "0px 0px 10px rgba(0, 0, 0, 0.7)" + : "0px 0px 10px rgba(0, 0, 0, 0.1)", + }, + gradientBlock: { + background: + mode === "dark" + ? `linear-gradient(180deg, ${lightGrey} 0%, rgba(61, 68, 127, 0.15)100%)` + : "linear-gradient(180deg, rgba(230, 232, 253, 0.50) 0%, rgba(61, 68, 127, 0.15) 100%)", + "&:hover": { + background: + mode === "dark" + ? `linear-gradient(180deg, rgba(61, 68, 127, 0.15) 0%, ${lightGrey} 100%)` + : "linear-gradient(180deg, rgba(61, 68, 127, 0.15) 0%, rgba(230, 232, 253, 0.50) 100%)", + }, + + ".MuiChip-root": { + backgroundColor: "#fff", + }, + }, + sources: { + iconWrap: { + background: "linear-gradient(90deg, #C398FA -56.85%, #7E6DBB 21.46%)", + svg: { + fill: "#ffffff !important", + color: "#ffffff", + }, + }, + sourceWrap: { + background: mode === "dark" ? "#1a1b27" : "#ffffff70", + border: `1px solid ${mode === "dark" ? "rgba(230, 232, 253, 0.30)" : lightPurple}`, + color: mode === "dark" ? "#fff" : deepPurple, + }, + sourceChip: { + background: mode === "dark" ? "#1a1b27" : "#ffffff", + border: `1px solid ${mode === "dark" ? "#c398fa" : "rgba(73, 83, 213, 0.40)"}`, + color: mode === "dark" ? "#fff" : "#444", + }, + }, + audioProgress: { + stroke: mode === "dark" ? "#c9d1d9" : "#6b7280", + }, + audioEditButton: { + boxShadow: "none", + border: "none", + backgroundColor: "transparent", + color: mode === "dark" ? "#fff" : deepPurple, + "&:hover": { + backgroundColor: mode === "dark" ? deepPurple : deepPurple + "40", + }, + }, + homeTitle: { + background: + mode === "dark" + ? "#fff" + : `linear-gradient(271deg, #C398FA -56.85%, #7E6DBB 21.46%, ${deepPurple} 99.77%)`, + }, + homeButtons: { + borderRadius: "25px", + border: `1px solid ${mode === "dark" ? white60 : deepPurple + "60"}`, // take purple down some it down some + backgroundColor: mode === "dark" ? "#161b22" : lightBg, + color: mode === "dark" ? "#fff" : deepPurple, + + boxShadow: + mode === "dark" + ? "0px 4px 10px rgba(0, 0, 0, 0.7)" + : "0px 4px 10px rgba(0, 0, 0, 0.1)", + "&:hover": { + backgroundColor: mode === "dark" ? darkPurple : lightPurple, + }, + fontWeight: 300, + '&[aria-selected="true"]': { + fontWeight: 600, + backgroundColor: mode === "dark" ? darkPurple : lightPurple, + }, + }, + promptExpandButton: { + borderRadius: "25px", + border: `1px solid ${mode === "dark" ? white60 : deepPurple + "60"}`, // take purple down some it down some + backgroundColor: mode === "dark" ? "#161b22" : lightBg, + color: mode === "dark" ? "#fff" : deepPurple, + + boxShadow: + mode === "dark" + ? "0px 4px 10px rgba(0, 0, 0, 0.7)" + : "0px 4px 10px rgba(0, 0, 0, 0.1)", + "&:hover": { + backgroundColor: mode === "dark" ? deepPurple : lightPurple, + }, + }, + promptButton: { + backgroundColor: mode === "dark" ? lightGrey : lightBg, + color: `${mode === "dark" ? "#fff" : deepPurple} !important`, + "&:hover": { + backgroundColor: mode === "dark" ? darkPurple : lightPurple, + color: mode === "dark" ? "#ffffff" : deepPurple, + }, + }, + promptListWrapper: { + backgroundColor: mode === "dark" ? lightGrey : lightBg, + boxShadow: + mode === "dark" + ? "0px 4px 10px rgba(0, 0, 0, 0.7)" + : "0px 4px 10px rgba(0, 0, 0, 0.1)", + }, + primaryInput: { + inputWrapper: { + backgroundColor: mode === "dark" ? lightGrey : lightPurple, + border: `1px solid ${mode === "dark" ? "#ffffff20" : deepPurple + "10"}`, + boxShadow: + mode === "dark" + ? "0px 0px 10px rgba(0, 0, 0, 0.3)" + : "0px 0px 10px rgba(0, 0, 0, 0.1)", + "&:hover, &.active, &:focus": { + border: `1px solid ${mode === "dark" ? "#ffffff20" : deepPurple + "60"}`, + }, + }, + textInput: { + color: mode === "dark" ? "#fff" : "#3D447F", + "&::placeholder": { + color: mode === "dark" ? "#ffffff90" : "#6b7280", + }, + }, + circleButton: { + backgroundColor: mode === "dark" ? "transparent" : deepPurple + "80", + border: `1px solid ${mode === "dark" ? white60 : "transparent"}`, + "svg path": { + fill: mode === "dark" ? "#c9d1d9" : "#D9D9D9", + }, + "&.active": { + backgroundColor: mode === "dark" ? deepPurple : lightGrey, + "svg path": { + fill: mode === "dark" ? "#c9d1d9" : "#D9D9D9", + }, + }, + "&:hover": { + backgroundColor: mode === "dark" ? "#646999" : "#003E71", + "svg path": { + fill: mode === "dark" ? "#c9d1d9" : "#D9D9D9", + }, + }, + }, + }, + tokensInput: { + color: mode === "dark" ? "#fff" : deepPurple, + backgroundColor: "transparent", + border: `1px solid ${mode === "dark" ? white60 : deepPurple + "70"}`, + boxShadow: "none", + + "&:hover": { + borderColor: deepPurple, + }, + + "&:focus": { + borderColor: deepPurple, + }, + + "&[aria-invalid]": { + borderColor: "#cc0000 !important", + color: "#cc0000", + }, + }, + webInput: { + backgroundColor: mode === "dark" ? lightGrey : lightPurple, + ".Mui-focused": { + color: mode === "dark" ? "#ffffff" : deepPurple, + ".MuiOutlinedInput-notchedOutline": { + border: `1px solid ${mode === "dark" ? white60 : `${deepPurple}22`}`, + }, + }, + }, + fileInputWrapper: { + backgroundColor: `${deepPurple}10`, + border: `1px dashed ${mode === "dark" ? white60 : `${deepPurple}22`}`, + }, + fileInput: { + wrapper: { + backgroundColor: `${deepPurple}10`, + border: `1px dashed ${mode === "dark" ? white60 : `${deepPurple}22`}`, + }, + file: { + backgroundColor: + mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,0.7)", + }, + }, + actionButtons: { + text: { + boxShadow: "none", + background: "none", + fontWeight: "400", + color: mode === "dark" ? "#ffffff" : "#007ce1", + "&:disabled": { + opacity: 0.5, + color: mode === "dark" ? "#ffffff" : "#007ce1", + }, + "&:hover": { + background: mode === "dark" ? "#007ce1" : "#ffffff", + color: mode === "dark" ? "#ffffff" : "#007ce1", + }, + }, + delete: { + boxShadow: "none", + background: "#f15346", + fontWeight: "400", + color: "#fff", + "&:hover": { + background: "#cc0000", + }, + "&:disabled": { + opacity: 0.5, + color: "#fff", + }, + }, + solid: { + boxShadow: "none", + background: deepPurple, + fontWeight: "400", + color: "#fff", + "&:hover": { + background: deepPurple, + }, + "&:disabled": { + opacity: 0.5, + color: "#fff", + }, + }, + outline: { + boxShadow: "none", + background: "transparent", + fontWeight: "400", + color: mode === "dark" ? "#ffffff" : "#007ce1", + border: `1px solid ${mode === "dark" ? "#ffffff" : "#007ce1"}`, + "&:hover": { + background: mode === "dark" ? "#007ce1" : "#ffffff", + color: mode === "dark" ? "#ffffff" : "#007ce1", + }, + "&.active": { + background: mode === "dark" ? "#ffffff" : "#007ce1", + color: mode === "dark" ? "#007ce1" : "#ffffff", + }, + }, + }, + themeToggle: { + ".MuiSwitch-switchBase.Mui-checked": { + ".MuiSwitch-thumb:before": { + backgroundImage: `url(${moonIcon})`, + }, + }, + "& .MuiSwitch-thumb": { + backgroundColor: mode === "dark" ? "#fff" : "transparent", + border: `1px solid ${mode === "dark" ? "#090B1C" : deepPurple}`, + "svg path": { + fill: mode === "dark" ? "#E5E7FE" : deepPurple, + }, + "&::before": { + backgroundImage: `url(${sunIcon})`, + }, + }, + "& .MuiSwitch-track": { + border: `1px solid ${mode === "dark" ? "#fff" : deepPurple}`, + backgroundColor: mode === "dark" ? "#8796A5" : "transparent", + }, + }, + dropDown: { + "&:hover, &:focus": { + backgroundColor: + mode === "dark" ? "rgba(0,0,0, 0.5)" : "rgba(230, 232, 253, 0.50)", + }, + "&.Mui-selected": { + backgroundColor: + mode === "dark" ? "rgba(0,0,0, 1)" : "rgba(230, 232, 253, 0.75)", + }, + "&.Mui-selected:hover, &.Mui-selected:focus": { + backgroundColor: + mode === "dark" ? "rgba(0,0,0, 1)" : "rgba(230, 232, 253, 0.75)", + }, + wrapper: { + border: `1px solid ${mode === "dark" ? white60 : deepPurple + "70"}`, + }, + }, + settingsModal: { + boxShadow: " 0px 0px 20px rgba(0,0,0,0.5)", + border: "1px solid #000", + background: mode === "dark" ? lightGrey : lightBg, + "#modal-modal-title": { + backgroundColor: "#e5e7fe", + color: deepPurple, + + svg: { + fill: deepPurple, + }, + }, + }, + styledSlider: { + color: mode === "dark" ? brightPurple : deepPurple, + + "&.disabled": { + color: mode === "dark" ? brightPurple : deepPurple, + }, + + ".MuiSlider-rail": { + backgroundColor: mode === "dark" ? brightPurple : deepPurple, + }, + + ".MuiSlider-track": { + backgroundColor: mode === "dark" ? brightPurple : deepPurple, + }, + + ".MuiSlider-thumb": { + backgroundColor: mode === "dark" ? brightPurple : deepPurple, + + "&:hover": { + boxShadow: `0 0 0 6px rgba(61,68,127,0.3)`, + }, + + "&.focusVisible": { + boxShadow: `0 0 0 8px rgba(61,68,127,0.5)`, + }, + + "&.active": { + boxShadow: `0 0 0 8px rgba(61,68,127,0.5)`, + }, + + "&.disabled": { + backgroundColor: mode === "dark" ? brightPurple : deepPurple, + }, + }, + }, + }, + }); +}; +deepPurple; diff --git a/app-frontend/react/src/types/common.ts b/app-frontend/react/src/types/common.ts new file mode 100644 index 0000000..eb65a08 --- /dev/null +++ b/app-frontend/react/src/types/common.ts @@ -0,0 +1,13 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +export interface ErrorResponse { + response?: { + data?: { + error?: { + message?: string; + }; + }; + }; + message: string; +} diff --git a/app-frontend/react/src/types/conversation.ts b/app-frontend/react/src/types/conversation.ts new file mode 100644 index 0000000..439d998 --- /dev/null +++ b/app-frontend/react/src/types/conversation.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +// export interface Model { +// model_type: string; +// token_limit: number; +// temperature: number; +// display_name: string; +// version: number; +// vendor: string; +// platform: string; +// min_temperature: number; +// max_temperature: number; +// min_token_limit: number; +// max_token_limit: number; +// data_insights_input_token: number; +// data_insights_output_token: number; +// } + +export interface InferenceSettings { + model: string; + temperature: number; + token_limit: number; + input_token?: number; + output_token?: number; + tags?: null; + maxTokenLimit?: number; + minTokenLimit?: number; + maxTemperatureLimit?: number; + minTemperatureLimit?: number; +} + +export interface Feedback { + comment: string; + rating: number; + is_thumbs_up: boolean; +} + +export interface SuccessResponse { + message: string; +} + +export interface PromptsResponse { + prompt_text: string; + tags: []; + tag_category: string; + author: string; +} + +export interface StreamChatProps { + user_id: string; + conversation_id: string; + use_case: string; + query: string; + tags: string[]; + settings: InferenceSettings; +} diff --git a/app-frontend/react/src/types/global.d.ts b/app-frontend/react/src/types/global.d.ts new file mode 100644 index 0000000..221d7c0 --- /dev/null +++ b/app-frontend/react/src/types/global.d.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +declare module "*.svg" { + const content: string; + export default content; +} diff --git a/app-frontend/react/src/types/speech.d.ts b/app-frontend/react/src/types/speech.d.ts new file mode 100644 index 0000000..1d5eb60 --- /dev/null +++ b/app-frontend/react/src/types/speech.d.ts @@ -0,0 +1,27 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +type SpeechRecognitionErrorEvent = Event & { + error: + | "no-speech" + | "audio-capture" + | "not-allowed" + | "network" + | "aborted" + | "service-not-allowed" + | "bad-grammar" + | "language-not-supported"; + message?: string; // Some browsers may provide an additional error message +}; + +type SpeechRecognitionEvent = Event & { + results: { + [index: number]: { + [index: number]: { + transcript: string; + confidence: number; + }; + isFinal: boolean; + }; + }; +}; diff --git a/app-frontend/react/src/types/styles.d.ts b/app-frontend/react/src/types/styles.d.ts new file mode 100644 index 0000000..7d3279f --- /dev/null +++ b/app-frontend/react/src/types/styles.d.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/app-frontend/react/src/types/theme.d.ts b/app-frontend/react/src/types/theme.d.ts new file mode 100644 index 0000000..a46a8af --- /dev/null +++ b/app-frontend/react/src/types/theme.d.ts @@ -0,0 +1,47 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import "@mui/material/styles"; +import { PaletteChip, PaletteColor } from "@mui/material/styles"; + +declare module "@mui/material/styles" { + interface Theme { + customStyles: Record>; + } + + interface ThemeOptions { + customStyles?: Record>; + } + + interface Palette { + header?: PaletteColor; + aside?: PaletteColor; + customDivider?: PaletteColor; + input?: PaletteColor; + icon?: PaletteColor; + user?: PaletteColor; + code?: PaletteColor; + gradientBlock?: PaletteColor; + audioProgress?: PaletteColor; + primaryInput?: PaletteColor; + actionButtons?: PaletteColor; + themeToggle?: PaletteColor; + dropDown?: PaletteColor; + } + + interface PaletteOptions { + header?: PaletteColorOptions; + aside?: PaletteColorOptions; + customDivider?: PaletteColorOptions; + input?: PaletteColorOptions; + icon?: PaletteColorOptions; + user?: PaletteColorOptions; + code?: PaletteColorOptions; + gradientBlock?: PaletteColorOptions; + audioProgress?: PaletteColorOptions; + primaryInput?: PaletteColorOptions; + actionButtons?: PaletteColorOptions; + themeToggle?: PaletteColorOptions; + dropDown?: PaletteColorOptions; + } +} diff --git a/app-frontend/react/src/utils/utils.js b/app-frontend/react/src/utils/utils.js new file mode 100644 index 0000000..59f40b5 --- /dev/null +++ b/app-frontend/react/src/utils/utils.js @@ -0,0 +1,96 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import React from "react"; + +export const smartTrim = (string, maxLength) => { + if (!string) { + return string; + } + if (maxLength < 1) { + return string; + } + if (string.length <= maxLength) { + return string; + } + if (maxLength === 1) { + return string.substring(0, 1) + "..."; + } + var midpoint = Math.ceil(string.length / 2); + var toremove = string.length - maxLength; + var lstrip = Math.ceil(toremove / 2); + var rstrip = toremove - lstrip; + return string.substring(0, midpoint - lstrip) + "..." + string.substring(midpoint + rstrip); +}; + +export const QueryStringFromArr = (paramsArr = []) => { + const queryString = []; + + for (const param of paramsArr) { + queryString.push(`${param.name}=${param.value}`); + } + + return queryString.join("&"); +}; + +export const isAuthorized = ( + allowedRoles = [], + userRole, + isPreviewOnlyFeature = false, + isPreviewUser = false, + isNotAllowed = false, +) => { + return ( + (allowedRoles.length === 0 || allowedRoles.includes(userRole)) && + (!isPreviewOnlyFeature || isPreviewUser) && + !isNotAllowed + ); +}; + +function addPropsToReactElement(element, props, i) { + if (React.isValidElement(element)) { + return React.cloneElement(element, { key: i, ...props }); + } + return element; +} + +export function addPropsToChildren(children, props) { + if (!Array.isArray(children)) { + return addPropsToReactElement(children, props); + } + return children.map((childElement, i) => addPropsToReactElement(childElement, props, i)); +} + +export const getCurrentTimeStamp = () => { + return Math.floor(Date.now() / 1000); +}; + +export const uuidv4 = () => { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16), + ); +}; + +export const readFilesAndSummarize = async (sourceFiles) => { + let summaryMessage = ""; + + if (sourceFiles.length) { + const readFilePromises = sourceFiles.map((fileWrapper) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result?.toString() || ""; + resolve(text); + }; + reader.onerror = () => reject(new Error("Error reading file")); + reader.readAsText(fileWrapper.file); + }); + }); + + const fileContents = await Promise.all(readFilePromises); + + summaryMessage = fileContents.join("\n"); + } + + return summaryMessage; +}; diff --git a/app-frontend/react/src/vite-env.d.ts b/app-frontend/react/src/vite-env.d.ts index 4260915..0128e66 100644 --- a/app-frontend/react/src/vite-env.d.ts +++ b/app-frontend/react/src/vite-env.d.ts @@ -1,4 +1,5 @@ -// Copyright (C) 2024 Intel Corporation +// Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 /// +/// diff --git a/app-frontend/react/tsconfig.json b/app-frontend/react/tsconfig.json index f50b75c..d7149ff 100644 --- a/app-frontend/react/tsconfig.json +++ b/app-frontend/react/tsconfig.json @@ -1,23 +1,34 @@ { "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, "skipLibCheck": true, - - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "baseUrl": "src", + "paths": { + "@components/*": ["components/*"], + "@shared/*": ["shared/*"], + "@contexts/*": ["contexts/*"], + "@redux/*": ["redux/*"], + "@services/*": ["services/*"], + "@pages/*": ["pages/*"], + "@layouts/*": ["layouts/*"], + "@assets/*": ["assets/*"], + "@icons/*": ["icons/*"], + "@utils/*": ["utils/*"], + "@root/*": ["*"] + } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src", "src/theme/theme.tsx", "src/**/*.d.ts"] } diff --git a/app-frontend/react/vite.config.js b/app-frontend/react/vite.config.js new file mode 100644 index 0000000..bf36019 --- /dev/null +++ b/app-frontend/react/vite.config.js @@ -0,0 +1,120 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import react from "@vitejs/plugin-react"; +import path from "path"; + +import { defineConfig } from "vite"; +import { visualizer } from "rollup-plugin-visualizer"; +import compression from "vite-plugin-compression"; +import terser from "@rollup/plugin-terser"; +import sassDts from "vite-plugin-sass-dts"; +import svgr from "vite-plugin-svgr"; + +export default defineConfig({ + base: "/", + optimizeDeps: { + include: ["**/*.scss"], // Include all .scss files + }, + modulePreload: { + polyfill: true, // Ensures compatibility + }, + css: { + modules: { + // Enable CSS Modules for all .scss files + localsConvention: "camelCaseOnly", + }, + }, + commonjsOptions: { + esmExternals: true, + }, + server: { + // https: true, + host: "0.0.0.0", + port: 5173, + }, + build: { + sourcemap: false, + rollupOptions: { + // output: { + // manualChunks(id) { + // if (id.includes('node_modules')) { + + // if (id.match(/react-dom|react-router|react-redux/)) { + // return 'react-vendor'; + // } + + // // // Code render files + // // if (id.match(/react-syntax-highlighter|react-markdown|gfm|remark|refractor|micromark|highlight|mdast/)) { + // // return 'code-vendor'; + // // } + + // if (id.match(/emotion|mui|styled-components/)) { + // return 'style-vendor'; + // } + + // if (id.match(/keycloak-js|axios|notistack|reduxjs|fetch-event-source|azure/)) { + // return 'utils-vendor'; + // } + + // const packages = id.toString().split('node_modules/')[1].split('/')[0]; + // return `vendor-${packages}`; + // } + // } + // }, + plugins: [ + terser({ + format: { comments: false }, + compress: { + drop_console: false, + drop_debugger: false, + }, + }), + ], + }, + chunkSizeWarningLimit: 500, + assetsInlineLimit: 0, + }, + plugins: [ + svgr(), + react(), + // sassDts({ + // enabledMode: []//['production'], // Generate type declarations on build + // }), + compression({ + algorithm: "gzip", + ext: ".gz", + deleteOriginFile: false, + threshold: 10240, + }), + visualizer({ + filename: "./dist/stats.html", // Output stats file + open: true, // Automatically open in the browser + gzipSize: true, // Show gzipped sizes + brotliSize: true, // Show Brotli sizes + }), + ], + resolve: { + alias: { + "@mui/styled-engine": "@mui/styled-engine-sc", + "@components": path.resolve(__dirname, "src/components/"), + "@shared": path.resolve(__dirname, "src/shared/"), + "@contexts": path.resolve(__dirname, "src/contexts/"), + "@redux": path.resolve(__dirname, "src/redux/"), + "@services": path.resolve(__dirname, "src/services/"), + "@pages": path.resolve(__dirname, "src/pages/"), + "@layouts": path.resolve(__dirname, "src/layouts/"), + "@assets": path.resolve(__dirname, "src/assets/"), + "@utils": path.resolve(__dirname, "src/utils/"), + "@icons": path.resolve(__dirname, "src/icons/"), + "@root": path.resolve(__dirname, "src/"), + }, + }, + define: { + "import.meta.env": process.env, + }, + assetsInclude: ["**/*.svg"], // Ensure Vite processes .svg files + // define: { + // "import.meta.env": process.env, + // }, +}); diff --git a/app-frontend/react/vite.config.ts b/app-frontend/react/vite.config.ts deleted file mode 100644 index 0c94d87..0000000 --- a/app-frontend/react/vite.config.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (C) 2024 Intel Corporation -// SPDX-License-Identifier: Apache-2.0 - -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; - -// https://vitejs.dev/config/ -export default defineConfig({ - css: { - preprocessorOptions: { - scss: { - additionalData: `@import "./src/styles/styles.scss";`, - }, - }, - }, - plugins: [react()], - server: { - port: 80, - }, - test: { - globals: true, - environment: "jsdom", - }, - define: { - "import.meta.env": process.env, - }, - build: { - target: "es2022" - }, - esbuild: { - target: "es2022" - }, - optimizeDeps:{ - esbuildOptions: { - target: "es2022", - } - } -});