diff --git a/.github/workflows/scripts/build_push.sh b/.github/workflows/scripts/build_push.sh index 89246006f..ac9b07369 100755 --- a/.github/workflows/scripts/build_push.sh +++ b/.github/workflows/scripts/build_push.sh @@ -52,6 +52,9 @@ for MEGA_SVC in $1; do docker_build ${IMAGE_NAME} cd ui docker_build ${IMAGE_NAME}-ui docker/Dockerfile + if [ "$MEGA_SVC" == "ChatQnA" ];then + docker_build ${IMAGE_NAME}-conversation-ui docker/Dockerfile.react + fi ;; "AudioQnA"|"SearchQnA"|"VisualQnA") echo "Not supported yet" diff --git a/ChatQnA/assets/img/conversation_ui_init.png b/ChatQnA/assets/img/conversation_ui_init.png new file mode 100644 index 000000000..6bd5f2447 Binary files /dev/null and b/ChatQnA/assets/img/conversation_ui_init.png differ diff --git a/ChatQnA/assets/img/conversation_ui_response.png b/ChatQnA/assets/img/conversation_ui_response.png new file mode 100644 index 000000000..be9ed6045 Binary files /dev/null and b/ChatQnA/assets/img/conversation_ui_response.png differ diff --git a/ChatQnA/assets/img/conversation_ui_upload.png b/ChatQnA/assets/img/conversation_ui_upload.png new file mode 100644 index 000000000..b05c33819 Binary files /dev/null and b/ChatQnA/assets/img/conversation_ui_upload.png differ diff --git a/ChatQnA/docker/ui/docker/Dockerfile.react b/ChatQnA/docker/ui/docker/Dockerfile.react new file mode 100644 index 000000000..277546a9b --- /dev/null +++ b/ChatQnA/docker/ui/docker/Dockerfile.react @@ -0,0 +1,22 @@ +FROM node as vite-app + +COPY . /usr/app +WORKDIR /usr/app/react + +ARG BACKEND_SERVICE_ENDPOINT +ARG DATAPREP_SERVICE_ENDPOINT +ENV VITE_BACKEND_SERVICE_ENDPOINT=$BACKEND_SERVICE_ENDPOINT +ENV VITE_DATA_PREP_SERVICE_URL=$DATAPREP_SERVICE_ENDPOINT + +RUN ["npm", "install"] +RUN ["npm", "run", "build"] + + +FROM nginx:alpine +EXPOSE 80 + + +COPY --from=vite-app /usr/app/react/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=vite-app /usr/app/react/dist /usr/share/nginx/html + +ENTRYPOINT ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/ChatQnA/docker/ui/react/.env b/ChatQnA/docker/ui/react/.env new file mode 100644 index 000000000..e5d52f421 --- /dev/null +++ b/ChatQnA/docker/ui/react/.env @@ -0,0 +1,2 @@ +VITE_BACKEND_SERVICE_ENDPOINT=http://backend_address:8888/v1/chatqna +VITE_DATA_PREP_SERVICE_URL=http://backend_address:6007/v1/dataprep \ No newline at end of file diff --git a/ChatQnA/docker/ui/react/.eslintrc.cjs b/ChatQnA/docker/ui/react/.eslintrc.cjs new file mode 100644 index 000000000..78174f683 --- /dev/null +++ b/ChatQnA/docker/ui/react/.eslintrc.cjs @@ -0,0 +1,11 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, +}; diff --git a/ChatQnA/docker/ui/react/.gitignore b/ChatQnA/docker/ui/react/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/ChatQnA/docker/ui/react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ChatQnA/docker/ui/react/README.md b/ChatQnA/docker/ui/react/README.md new file mode 100644 index 000000000..fd5a2d342 --- /dev/null +++ b/ChatQnA/docker/ui/react/README.md @@ -0,0 +1,32 @@ +

ChatQnA Conversational UI

+ +### 📸 Project Screenshots + +![project-screenshot](../../../assets/img/conversation_ui_init.png) +![project-screenshot](../../../assets/img/conversation_ui_response.png) +![project-screenshot](../../../assets/img/conversation_ui_upload.png) + +

🧐 Features

+ +Here're some of the project's features: + +- Start a Text Chat:Initiate a text chat with the ability to input written conversations, where the dialogue content can also be customized based on uploaded files. +- Context Awareness: The AI assistant maintains the context of the conversation, understanding references to previous statements or questions. This allows for more natural and coherent exchanges. +- Upload File: The choice between uploading locally or copying a remote link. Chat according to uploaded knowledge base. +- Clear: Clear the record of the current dialog box without retaining the contents of the dialog box. +- Chat history: Historical chat records can still be retained after refreshing, making it easier for users to view the context. +- Conversational Chat : The application maintains a history of the conversation, allowing users to review previous messages and the AI to refer back to earlier points in the dialogue when necessary. + +

🛠️ Get it Running:

+ +1. Clone the repo. + +2. cd command to the current folder. + +3. Modify the required .env variables. + ``` + DOC_BASE_URL = '' + ``` +4. Execute `npm install` to install the corresponding dependencies. + +5. Execute `npm run dev` in both environments diff --git a/ChatQnA/docker/ui/react/index.html b/ChatQnA/docker/ui/react/index.html new file mode 100644 index 000000000..fbe87e0fd --- /dev/null +++ b/ChatQnA/docker/ui/react/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Conversations UI + + +
+ + + diff --git a/ChatQnA/docker/ui/react/nginx.conf b/ChatQnA/docker/ui/react/nginx.conf new file mode 100644 index 000000000..00433fcda --- /dev/null +++ b/ChatQnA/docker/ui/react/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + + gzip on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types font/woff2 text/css application/javascript application/json application/font-woff application/font-tff image/gif image/png image/svg+xml application/octet-stream; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html =404; + + location ~* \.(gif|jpe?g|png|webp|ico|svg|css|js|mp4|woff2)$ { + expires 1d; + } + } +} \ No newline at end of file diff --git a/ChatQnA/docker/ui/react/package.json b/ChatQnA/docker/ui/react/package.json new file mode 100644 index 000000000..3760ed909 --- /dev/null +++ b/ChatQnA/docker/ui/react/package.json @@ -0,0 +1,47 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "@mantine/core": "^7.10.0", + "@mantine/hooks": "^7.10.0", + "@mantine/notifications": "^7.10.2", + "@microsoft/fetch-event-source": "^2.0.1", + "@reduxjs/toolkit": "^2.2.5", + "@tabler/icons-react": "^3.5.0", + "axios": "^1.7.2", + "luxon": "^3.4.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-redux": "^9.1.2" + }, + "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" + } +} diff --git a/ChatQnA/docker/ui/react/postcss.config.cjs b/ChatQnA/docker/ui/react/postcss.config.cjs new file mode 100644 index 000000000..e817f567b --- /dev/null +++ b/ChatQnA/docker/ui/react/postcss.config.cjs @@ -0,0 +1,14 @@ +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/ChatQnA/docker/ui/react/public/vite.svg b/ChatQnA/docker/ui/react/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/ChatQnA/docker/ui/react/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ChatQnA/docker/ui/react/src/App.scss b/ChatQnA/docker/ui/react/src/App.scss new file mode 100644 index 000000000..187764a17 --- /dev/null +++ b/ChatQnA/docker/ui/react/src/App.scss @@ -0,0 +1,42 @@ +// 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; +} diff --git a/ChatQnA/docker/ui/react/src/App.tsx b/ChatQnA/docker/ui/react/src/App.tsx new file mode 100644 index 000000000..4be4fa5bb --- /dev/null +++ b/ChatQnA/docker/ui/react/src/App.tsx @@ -0,0 +1,34 @@ +// 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'; + +const title = "Chat QnA" +const navList: SidebarNavList = [ + { icon: IconMessages, label: title } +] + +function App() { + + return ( + + + +
+ +
+ +
+
+
+ ) +} + +export default App diff --git a/ChatQnA/docker/ui/react/src/__tests__/util.test.ts b/ChatQnA/docker/ui/react/src/__tests__/util.test.ts new file mode 100644 index 000000000..e67ba2c86 --- /dev/null +++ b/ChatQnA/docker/ui/react/src/__tests__/util.test.ts @@ -0,0 +1,14 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from "vitest"; +import { getCurrentTimeStamp, uuidv4 } from "../common/util"; + +describe("unit tests", () => { + test("check UUID is of length 36", () => { + expect(uuidv4()).toHaveLength(36); + }); + test("check TimeStamp generated is of unix", () => { + expect(getCurrentTimeStamp()).toBe(Math.floor(Date.now() / 1000)); + }); +}); diff --git a/ChatQnA/docker/ui/react/src/assets/opea-icon-black.svg b/ChatQnA/docker/ui/react/src/assets/opea-icon-black.svg new file mode 100644 index 000000000..d4e5cfda9 --- /dev/null +++ b/ChatQnA/docker/ui/react/src/assets/opea-icon-black.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatQnA/docker/ui/react/src/assets/opea-icon-color.svg b/ChatQnA/docker/ui/react/src/assets/opea-icon-color.svg new file mode 100644 index 000000000..790151171 --- /dev/null +++ b/ChatQnA/docker/ui/react/src/assets/opea-icon-color.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatQnA/docker/ui/react/src/assets/react.svg b/ChatQnA/docker/ui/react/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/ChatQnA/docker/ui/react/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ChatQnA/docker/ui/react/src/common/client.ts b/ChatQnA/docker/ui/react/src/common/client.ts new file mode 100644 index 000000000..7512f73e3 --- /dev/null +++ b/ChatQnA/docker/ui/react/src/common/client.ts @@ -0,0 +1,8 @@ +// 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/ChatQnA/docker/ui/react/src/common/util.ts b/ChatQnA/docker/ui/react/src/common/util.ts new file mode 100644 index 000000000..df65b2d8e --- /dev/null +++ b/ChatQnA/docker/ui/react/src/common/util.ts @@ -0,0 +1,12 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +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), + ); +}; diff --git a/ChatQnA/docker/ui/react/src/components/Conversation/Conversation.tsx b/ChatQnA/docker/ui/react/src/components/Conversation/Conversation.tsx new file mode 100644 index 000000000..02736d8bd --- /dev/null +++ b/ChatQnA/docker/ui/react/src/components/Conversation/Conversation.tsx @@ -0,0 +1,156 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { KeyboardEventHandler, SyntheticEvent, useEffect, useRef, useState } from 'react' +import styleClasses from "./conversation.module.scss" +import { ActionIcon, Group, Textarea, Title, rem } from '@mantine/core' +import { IconArrowRight, IconFilePlus, IconMessagePlus } from '@tabler/icons-react' +import { conversationSelector, doConversation, newConversation } from '../../redux/Conversation/ConversationSlice' +import { ConversationMessage } from '../Message/conversationMessage' +import { useAppDispatch, useAppSelector } from '../../redux/store' +import { Message, MessageRole } from '../../redux/Conversation/Conversation' +import { getCurrentTimeStamp } from '../../common/util' +import { useDisclosure } from '@mantine/hooks' +import DataSource from './DataSource' +import { ConversationSideBar } from './ConversationSideBar' + +type ConversationProps = { + title:string +} + +const Conversation = ({ title }: ConversationProps) => { + + const [prompt, setPrompt] = useState("") + const promptInputRef = useRef(null) + const [fileUploadOpened, { open: openFileUpload, close: closeFileUpload }] = useDisclosure(false); + + const { conversations, onGoingResult, selectedConversationId } = useAppSelector(conversationSelector) + const dispatch = useAppDispatch(); + const selectedConversation = conversations.find(x=>x.conversationId===selectedConversationId) + + const scrollViewport = useRef(null) + + const toSend = "Enter" + + const systemPrompt: Partial = { + role: MessageRole.System, + content: "You are helpful assistant", + }; + + + const handleSubmit = () => { + + const userPrompt: Message = { + role: MessageRole.User, + content: prompt, + time: getCurrentTimeStamp() + }; + let messages: Partial[] = []; + if(selectedConversation){ + messages = selectedConversation.Messages.map(message => { + return {role:message.role, content:message.content} + }) + } + + messages = [systemPrompt, ...messages] + + doConversation({ + conversationId: selectedConversationId, + userPrompt, + messages, + model: "Intel/neural-chat-7b-v3-3", + }) + setPrompt("") + } + + const scrollToBottom = () => { + scrollViewport.current!.scrollTo({ top: scrollViewport.current!.scrollHeight }) + } + + useEffect(() => { + scrollToBottom() + }, [onGoingResult, selectedConversation?.Messages]) + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (!event.shiftKey && event.key === toSend) { + handleSubmit() + setTimeout(() => { + setPrompt("") + }, 1) + } + } + + + + const handleNewConversation = () => { + dispatch(newConversation()) + } + + const handleChange = (event: SyntheticEvent) => { + event.preventDefault() + setPrompt((event.target as HTMLTextAreaElement).value) + } + return ( +
+ +
+
+
+ {selectedConversation?.title || ""} + + + {selectedConversation && selectedConversation?.Messages.length > 0 && ( + + + + )} + + + + +
+ +
+ + {!selectedConversation && ( + <> +
Start by asking a question
+
You can also upload your Document by clicking on Document icon on top right corner
+ + )} + + {selectedConversation?.Messages.map((message) => { + return () + }) + } + + {onGoingResult && ( + + )} +
+ +
+