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" 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 0000000..fa313ab Binary files /dev/null and b/app-frontend/react/public/logo192.png differ diff --git a/app-frontend/react/public/logo512.png b/app-frontend/react/public/logo512.png new file mode 100644 index 0000000..bd5d4b5 Binary files /dev/null and b/app-frontend/react/public/logo512.png differ 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", - } - } -}); 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: