diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 6a2f3bea3..e305845d4 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -339,6 +339,9 @@ def post(self): ".json", ".xlsx", ".pptx", + ".png", + ".jpg", + ".jpeg", ], job_name, final_filename, @@ -365,6 +368,9 @@ def post(self): ".json", ".xlsx", ".pptx", + ".png", + ".jpg", + ".jpeg", ], job_name, final_filename, diff --git a/application/core/settings.py b/application/core/settings.py index d4b02481c..a7811ec78 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -18,6 +18,7 @@ class Settings(BaseSettings): DEFAULT_MAX_HISTORY: int = 150 MODEL_TOKEN_LIMITS: dict = {"gpt-3.5-turbo": 4096, "claude-2": 1e5} UPLOAD_FOLDER: str = "inputs" + PARSE_PDF_AS_IMAGE: bool = False VECTOR_STORE: str = "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" RETRIEVERS_ENABLED: list = ["classic_rag", "duckduck_search"] # also brave_search diff --git a/application/parser/file/bulk.py b/application/parser/file/bulk.py index 3b8fbca86..8201b3f22 100644 --- a/application/parser/file/bulk.py +++ b/application/parser/file/bulk.py @@ -13,6 +13,7 @@ from application.parser.file.tabular_parser import PandasCSVParser,ExcelParser from application.parser.file.json_parser import JSONParser from application.parser.file.pptx_parser import PPTXParser +from application.parser.file.image_parser import ImageParser from application.parser.schema.base import Document DEFAULT_FILE_EXTRACTOR: Dict[str, BaseParser] = { @@ -27,6 +28,9 @@ ".mdx": MarkdownParser(), ".json":JSONParser(), ".pptx":PPTXParser(), + ".png": ImageParser(), + ".jpg": ImageParser(), + ".jpeg": ImageParser(), } diff --git a/application/parser/file/docs_parser.py b/application/parser/file/docs_parser.py index 861e8e589..55d45a648 100644 --- a/application/parser/file/docs_parser.py +++ b/application/parser/file/docs_parser.py @@ -7,7 +7,8 @@ from typing import Dict from application.parser.file.base_parser import BaseParser - +from application.core.settings import settings +import requests class PDFParser(BaseParser): """PDF parser.""" @@ -18,6 +19,15 @@ def _init_parser(self) -> Dict: def parse_file(self, file: Path, errors: str = "ignore") -> str: """Parse file.""" + if settings.PARSE_PDF_AS_IMAGE: + doc2md_service = "https://llm.arc53.com/doc2md" + # alternatively you can use local vision capable LLM + with open(file, "rb") as file_loaded: + files = {'file': file_loaded} + response = requests.post(doc2md_service, files=files) + data = response.json()["markdown"] + return data + try: import PyPDF2 except ImportError: diff --git a/application/parser/file/image_parser.py b/application/parser/file/image_parser.py new file mode 100644 index 000000000..fd800d91c --- /dev/null +++ b/application/parser/file/image_parser.py @@ -0,0 +1,27 @@ +"""Image parser. + +Contains parser for .png, .jpg, .jpeg files. + +""" +from pathlib import Path +import requests +from typing import Dict, Union + +from application.parser.file.base_parser import BaseParser + + +class ImageParser(BaseParser): + """Image parser.""" + + def _init_parser(self) -> Dict: + """Init parser.""" + return {} + + def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, list[str]]: + doc2md_service = "https://llm.arc53.com/doc2md" + # alternatively you can use local vision capable LLM + with open(file, "rb") as file_loaded: + files = {'file': file_loaded} + response = requests.post(doc2md_service, files=files) + data = response.json()["markdown"] + return data diff --git a/extensions/react-widget/package-lock.json b/extensions/react-widget/package-lock.json index de4c228d6..6d736c6f0 100644 --- a/extensions/react-widget/package-lock.json +++ b/extensions/react-widget/package-lock.json @@ -4860,9 +4860,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001625", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001625.tgz", - "integrity": "sha512-4KE9N2gcRH+HQhpeiRZXd+1niLB/XNLAhSy4z7fI8EzcbcPoAqjNInxVHTiTwWfTIV4w096XG8OtCOCQQKPv3w==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "funding": [ { "type": "opencollective", @@ -4876,7 +4876,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "2.4.2", diff --git a/extensions/react-widget/package.json b/extensions/react-widget/package.json index a1097403a..12a669397 100644 --- a/extensions/react-widget/package.json +++ b/extensions/react-widget/package.json @@ -32,7 +32,8 @@ "scripts": { "build": "parcel build src/main.tsx --public-url ./", "build:react": "parcel build src/index.ts", - "dev": "parcel src/index.html -p 3000", + "serve": "parcel serve -p 3000", + "dev": "parcel -p 3000", "test": "jest", "lint": "eslint", "check": "tsc --noEmit", diff --git a/extensions/react-widget/src/App.tsx b/extensions/react-widget/src/App.tsx index ec9de47be..4bb24bae1 100644 --- a/extensions/react-widget/src/App.tsx +++ b/extensions/react-widget/src/App.tsx @@ -1,11 +1,11 @@ import React from "react" import {DocsGPTWidget} from "./components/DocsGPTWidget" -const App = () => { +import {SearchBar} from "./components/SearchBar" +export const App = () => { return (
+
) -} - -export default App \ No newline at end of file +} \ No newline at end of file diff --git a/extensions/react-widget/src/components/DocsGPTWidget.tsx b/extensions/react-widget/src/components/DocsGPTWidget.tsx index 1baa4c626..d6273eaa7 100644 --- a/extensions/react-widget/src/components/DocsGPTWidget.tsx +++ b/extensions/react-widget/src/components/DocsGPTWidget.tsx @@ -1,9 +1,9 @@ "use client"; import React, { useRef } from 'react' import DOMPurify from 'dompurify'; -import styled, { keyframes, createGlobalStyle } from 'styled-components'; +import styled, { keyframes, css } from 'styled-components'; import { PaperPlaneIcon, RocketIcon, ExclamationTriangleIcon, Cross2Icon } from '@radix-ui/react-icons'; -import { FEEDBACK, MESSAGE_TYPE, Query, Status, WidgetProps } from '../types/index'; +import { FEEDBACK, MESSAGE_TYPE, Query, Status, WidgetCoreProps, WidgetProps } from '../types/index'; import { fetchAnswerStreaming, sendFeedback } from '../requests/streamingApi'; import { ThemeProvider } from 'styled-components'; import Like from "../assets/like.svg" @@ -49,38 +49,8 @@ const sizesConfig = { maxHeight: custom.maxHeight || '70vh', }), }; - -const Overlay = styled.div` - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - z-index: 999; - transition: opacity 0.5s; -` -const WidgetContainer = styled.div<{ modal?: boolean, isOpen?: boolean }>` - all: initial; - position: fixed; - right: ${props => props.modal ? '50%' : '10px'}; - bottom: ${props => props.modal ? '50%' : '10px'}; - z-index: 1000; - display: none; - transform-origin:100% 100%; - &.open { - animation: createBox 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; - } - &.close { - animation: closeBox 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; - } - ${props => props.modal && - "transform : translate(50%,50%);" - } - align-items: center; - text-align: left; - @keyframes createBox { - 0% { +const createBox = keyframes` + 0% { transform: scale(0.6); } 90% { @@ -89,10 +59,9 @@ const WidgetContainer = styled.div<{ modal?: boolean, isOpen?: boolean }>` 100% { transform: scale(1); } - } - - @keyframes closeBox { - 0% { +` +const closeBox = keyframes` + 0% { transform: scale(1); } 10% { @@ -101,32 +70,9 @@ const WidgetContainer = styled.div<{ modal?: boolean, isOpen?: boolean }>` 100% { transform: scale(0.6); } - } -`; -const StyledContainer = styled.div<{ isOpen: boolean }>` - all: initial; - max-height: ${(props) => props.theme.dimensions.maxHeight}; - max-width: ${(props) => props.theme.dimensions.maxWidth}; - position: relative; - flex-direction: column; - justify-content: space-between; - bottom: 0; - left: 0; - background-color: ${(props) => props.theme.primary.bg}; - font-family: sans-serif; - display: flex; - border-radius: 12px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.1); - padding: 26px 26px 0px 26px; - animation: ${({ isOpen, theme }) => - theme.dimensions.size === 'large' - ? isOpen - ? 'fadeIn 150ms ease-in forwards' - : 'fadeOut 150ms ease-in forwards' - : isOpen - ? 'openContainer 150ms ease-in forwards' - : 'closeContainer 250ms ease-in forwards'}; - @keyframes openContainer { +` + +const openContainer = keyframes` 0% { width: 200px; height: 100px; @@ -135,10 +81,9 @@ const StyledContainer = styled.div<{ isOpen: boolean }>` width: ${(props) => props.theme.dimensions.width}; height: ${(props) => props.theme.dimensions.height}; border-radius: 12px; - } - } - @keyframes closeContainer { - 0% { + }` +const closeContainer = keyframes` + 0% { width: ${(props) => props.theme.dimensions.width}; height: ${(props) => props.theme.dimensions.height}; border-radius: 12px; @@ -147,9 +92,9 @@ const StyledContainer = styled.div<{ isOpen: boolean }>` width: 200px; height: 100px; } - } - @keyframes fadeIn { - from { +` +const fadeIn = keyframes` + from { opacity: 0; width: ${(props) => props.theme.dimensions.width}; height: ${(props) => props.theme.dimensions.height}; @@ -161,9 +106,10 @@ const StyledContainer = styled.div<{ isOpen: boolean }>` width: ${(props) => props.theme.dimensions.width}; height: ${(props) => props.theme.dimensions.height}; } - } - @keyframes fadeOut { - from { +` + +const fadeOut = keyframes` + from { opacity: 1; width: ${(props) => props.theme.dimensions.width}; height: ${(props) => props.theme.dimensions.height}; @@ -174,13 +120,80 @@ const StyledContainer = styled.div<{ isOpen: boolean }>` width: ${(props) => props.theme.dimensions.width}; height: ${(props) => props.theme.dimensions.height}; } +` +const scaleAnimation = keyframes` + from { + transform: scale(1.2); + } + to { + transform: scale(1); + } +` +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; + transition: opacity 0.5s; +` + + +const WidgetContainer = styled.div<{ modal?: boolean, isOpen?: boolean }>` + all: initial; + position: fixed; + right: ${props => props.modal ? '50%' : '10px'}; + bottom: ${props => props.modal ? '50%' : '10px'}; + z-index: 1001; + transform-origin:100% 100%; + display: block; + &.modal{ + transform : translate(50%,50%); } + &.open { + animation: css ${createBox} 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; + } + &.close { + animation: css ${closeBox} 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards; + } + align-items: center; + text-align: left; +`; + +const StyledContainer = styled.div<{ isOpen: boolean }>` + all: initial; + max-height: ${(props) => props.theme.dimensions.maxHeight}; + max-width: ${(props) => props.theme.dimensions.maxWidth}; + width: ${(props) => props.theme.dimensions.width}; + height: ${(props) => props.theme.dimensions.height} ; + position: relative; + flex-direction: column; + justify-content: space-between; + bottom: 0; + left: 0; + background-color: ${(props) => props.theme.primary.bg}; + font-family: sans-serif; + display: flex; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 26px 26px 0px 26px; + animation: ${({ isOpen, theme }) => + theme.dimensions.size === 'large' + ? isOpen + ? css`${fadeIn} 150ms ease-in forwards` + : css` ${fadeOut} 150ms ease-in forwards` + : isOpen + ? css`${openContainer} 150ms ease-in forwards` + : css`${closeContainer} 250ms ease-in forwards`}; @media only screen and (max-width: 768px) { max-height: 100vh; max-width: 80vw; overflow: auto; } `; + const FloatingButton = styled.div<{ bgcolor: string, hidden: boolean, isAnimatingButton: boolean }>` position: fixed; display: ${props => props.hidden ? "none" : "flex"}; @@ -198,7 +211,7 @@ const FloatingButton = styled.div<{ bgcolor: string, hidden: boolean, isAnimatin background: ${props => props.bgcolor}; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); cursor: pointer; - animation: ${props => props.isAnimatingButton ? 'scaleAnimation 200ms forwards' : 'none'}; + animation: ${props => props.isAnimatingButton ? css`${scaleAnimation} 200ms forwards` : 'none'}; &:hover { transform: scale(1.1); transition: transform 0.2s ease-in-out; @@ -206,15 +219,6 @@ const FloatingButton = styled.div<{ bgcolor: string, hidden: boolean, isAnimatin &:not(:hover) { transition: transform 0.2s ease-in-out; } - - @keyframes scaleAnimation { - from { - transform: scale(1.2); - } - to { - transform: scale(1); - } - } `; const CancelButton = styled.button` cursor: pointer; @@ -478,7 +482,47 @@ const Hero = ({ title, description, theme }: { title: string, description: strin ); }; -export const DocsGPTWidget = ({ +export const DocsGPTWidget = (props: WidgetProps) => { + + const { + buttonIcon = 'https://d3dg1063dc54p9.cloudfront.net/widget/chat.svg', + buttonText = 'Ask a question', + buttonBg = 'linear-gradient(to bottom right, #5AF0EC, #E80D9D)', + defaultOpen = false, + ...coreProps + } = props + + const [open, setOpen] = React.useState(defaultOpen); + const [isAnimatingButton, setIsAnimatingButton] = React.useState(false); + const [isFloatingButtonVisible, setIsFloatingButtonVisible] = React.useState(true); + + React.useEffect(() => { + if (isFloatingButtonVisible) + setTimeout(() => setIsAnimatingButton(true), 250); + return () => { + setIsAnimatingButton(false) + } + }, [isFloatingButtonVisible]) + + const handleClose = () => { + setIsFloatingButtonVisible(true); + setOpen(false); + }; + const handleOpen = () => { + setOpen(true); + setIsFloatingButtonVisible(false); + } + return ( + <> + + + + ) +} +export const WidgetCore = ({ apiHost = 'https://gptcloud.arc53.com', apiKey = '82962c9a-aa77-4152-94e5-a4f84fd44c6a', avatar = 'https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png', @@ -488,25 +532,37 @@ export const DocsGPTWidget = ({ heroDescription = 'This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources.', size = 'small', theme = 'dark', - buttonIcon = 'https://d3dg1063dc54p9.cloudfront.net/widget/chat.svg', - buttonText = 'Ask a question', - buttonBg = 'linear-gradient(to bottom right, #5AF0EC, #E80D9D)', collectFeedback = true, - deafultOpen = false -}: WidgetProps) => { - const [prompt, setPrompt] = React.useState(''); + isOpen = false, + prefilledQuery = "", + handleClose +}: WidgetCoreProps) => { + const [prompt, setPrompt] = React.useState(""); + const [mounted, setMounted] = React.useState(false); const [status, setStatus] = React.useState('idle'); - const [queries, setQueries] = React.useState([]) - const [conversationId, setConversationId] = React.useState(null) - const [open, setOpen] = React.useState(deafultOpen) + const [queries, setQueries] = React.useState([]); + const [conversationId, setConversationId] = React.useState(null); const [eventInterrupt, setEventInterrupt] = React.useState(false); //click or scroll by user while autoScrolling - const [isAnimatingButton, setIsAnimatingButton] = React.useState(false); - const [isFloatingButtonVisible, setIsFloatingButtonVisible] = React.useState(true); - const isBubbleHovered = useRef(false) - const widgetRef = useRef(null) + + const isBubbleHovered = useRef(false); const endMessageRef = React.useRef(null); const md = new MarkdownIt(); + React.useEffect(() => { + if (isOpen) { + setMounted(true); // Mount the component + appendQuery(prefilledQuery) + } else { + // Wait for animations before unmounting + const timeout = setTimeout(() => { + setMounted(false) + }, 250); + return () => clearTimeout(timeout); + } + }, [isOpen]); + + + const handleUserInterrupt = () => { (status === 'loading') && setEventInterrupt(true); } @@ -606,144 +662,138 @@ export const DocsGPTWidget = ({ } // submit handler const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + e.preventDefault(); + await appendQuery(prompt) + } + + const appendQuery = async (userQuery:string) => { + console.log(userQuery) + if(!userQuery) + return; + setEventInterrupt(false); - queries.push({ prompt }) - setPrompt('') - await stream(prompt) + queries.push({ prompt:userQuery}); + setPrompt(''); + await stream(userQuery); } const handleImageError = (event: React.SyntheticEvent) => { event.currentTarget.src = "https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"; }; - const handleClose = () => { - setOpen(false); - setTimeout(() => { - if (widgetRef.current) widgetRef.current.style.display = "none"; - setIsFloatingButtonVisible(true); - setIsAnimatingButton(true); - setTimeout(() => setIsAnimatingButton(false), 200); - }, 250) - }; - const handleOpen = () => { - setOpen(true); - setIsFloatingButtonVisible(false); - if (widgetRef.current) - widgetRef.current.style.display = 'block' - } + const dimensions = typeof size === 'object' && 'custom' in size ? sizesConfig.getCustom(size.custom) : sizesConfig[size]; - + if (!mounted) return null; return ( - {open && size === 'large' && + {isOpen && size === 'large' && } - - - { -
- - - -
- docs-gpt - - {title} - {description} - -
-
- - { - queries.length > 0 ? queries?.map((query, index) => { - return ( - - { - query.prompt && - - {query.prompt} - - - } - { - query.response ? { isBubbleHovered.current = true }} type='ANSWER'> - - - - - {collectFeedback && - - handleFeedback("LIKE", index)} /> - handleFeedback("DISLIKE", index)} /> - } - - :
- { - query.error ? - - -
-
Network Error
- {query.error} -
-
- : - - . - . - . - - - } -
- } -
) - }) - : - } -
-
- - setPrompt(event.target.value)} - type='text' placeholder="Ask your question" /> - - - - - - Powered by  - DocsGPT - -
-
} -
+ {( + + +
+ + + +
+ docs-gpt + + {title} + {description} + +
+
+ + { + queries.length > 0 ? queries?.map((query, index) => { + return ( + + { + query.prompt && + + {query.prompt} + + + } + { + query.response ? { isBubbleHovered.current = true }} type='ANSWER'> + + + + + {collectFeedback && + + handleFeedback("LIKE", index)} /> + handleFeedback("DISLIKE", index)} /> + } + + :
+ { + query.error ? + + +
+
Network Error
+ {query.error} +
+
+ : + + . + . + . + + + } +
+ } +
) + }) + : + } +
+
+ + setPrompt(event.target.value)} + type='text' placeholder="Ask your question" /> + + + + + + Powered by  + DocsGPT + +
+
+
+ ) + }
) } \ No newline at end of file diff --git a/extensions/react-widget/src/components/SearchBar.tsx b/extensions/react-widget/src/components/SearchBar.tsx new file mode 100644 index 000000000..aff036d07 --- /dev/null +++ b/extensions/react-widget/src/components/SearchBar.tsx @@ -0,0 +1,398 @@ +import React from 'react' +import styled, { keyframes, createGlobalStyle, ThemeProvider } from 'styled-components'; +import { WidgetCore } from './DocsGPTWidget'; +import { SearchBarProps } from '@/types'; +import { getSearchResults } from '../requests/searchAPI' +import { Result } from '@/types'; +import MarkdownIt from 'markdown-it'; +import DOMPurify from 'dompurify'; +import { getOS } from '../utils/helper' +const themes = { + dark: { + bg: '#000', + text: '#fff', + primary: { + text: "#FAFAFA", + bg: '#111111' + }, + secondary: { + text: "#A1A1AA", + bg: "#38383b" + } + }, + light: { + bg: '#fff', + text: '#000', + primary: { + text: "#222327", + bg: "#fff" + }, + secondary: { + text: "#A1A1AA", + bg: "#F6F6F6" + } + } +} + +const Main = styled.div` + all:initial; + font-family: sans-serif; +` +const TextField = styled.input<{ inputWidth: string }>` + padding: 6px 6px; + width: ${({ inputWidth }) => inputWidth}; + border-radius: 8px; + display: inline; + color: ${props => props.theme.primary.text}; + outline: none; + border: none; + background-color: ${props => props.theme.secondary.bg}; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + transition: background-color 128ms linear; + &:focus { + outline: none; + box-shadow: + 0px 0px 0px 2px rgba(0, 109, 199), + 0px 0px 6px rgb(0, 90, 163), + 0px 2px 6px rgba(0, 0, 0, 0.1) ; + background-color: ${props => props.theme.primary.bg}; + } +` + +const Container = styled.div` + position: relative; + display: inline-block; +` +const SearchResults = styled.div` + position: absolute; + display: block; + background-color: ${props => props.theme.primary.bg}; + opacity: 90%; + border: 1px solid rgba(0, 0, 0, .1); + border-radius: 12px; + padding: 8px; + width: 576px; + min-width: 96%; + z-index: 100; + height: 25vh; + overflow-y: auto; + top: 32px; + color: ${props => props.theme.primary.text}; + scrollbar-color: lab(48.438 0 0 / 0.4) rgba(0, 0, 0, 0); + scrollbar-gutter: stable; + scrollbar-width: thin; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(16px); + @media only screen and (max-width: 768px) { + max-height: 100vh; + max-width: 80vw; + overflow: auto; + } +` +const Title = styled.h3` + font-size: 14px; + color: ${props => props.theme.primary.text}; + opacity: 0.8; + padding-bottom: 6px; + font-weight: 600; + text-transform: uppercase; + border-bottom: 1px solid ${(props) => props.theme.secondary.text}; +` +const Content = styled.div` + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +` +const ResultWrapper = styled.div` + padding: 4px 8px 4px 8px; + border-radius: 8px; + cursor: pointer; + &.contains-source:hover{ + background-color: rgba(0, 92, 197, 0.15); + ${Title} { + color: rgb(0, 126, 230); + } + } +` +const Markdown = styled.div` +line-height:20px; +font-size: 12px; +word-break: break-all; + pre { + padding: 8px; + width: 90%; + font-size: 12px; + border-radius: 6px; + overflow-x: auto; + background-color: #1B1C1F; + color: #fff ; + } + + h1,h2 { + font-size: 16px; + font-weight: 600; + color: ${(props) => props.theme.text}; + opacity: 0.8; + } + + + h3 { + font-size: 14px; + } + + p { + margin: 0px; + line-height: 1.35rem; + font-size: 12px; + } + + code:not(pre code) { + border-radius: 6px; + padding: 4px 4px; + font-size: 12px; + display: inline-block; + background-color: #646464; + color: #fff ; + } + + code { + white-space: pre-wrap ; + overflow-wrap: break-word; + word-break: break-all; + } + a{ + color: #007ee6; + } +` +const Toolkit = styled.kbd` + position: absolute; + right: 4px; + top: 4px; + background-color: ${(props) => props.theme.primary.bg}; + color: ${(props) => props.theme.secondary.text}; + font-weight: 600; + font-size: 10px; + padding: 3px; + border: 1px solid ${(props) => props.theme.secondary.text}; + border-radius: 4px; +` +const Loader = styled.div` + margin: 2rem auto; + border: 4px solid ${props => props.theme.secondary.text}; + border-top: 4px solid ${props => props.theme.primary.bg}; + border-radius: 50%; + width: 12px; + height: 12px; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +`; + +const NoResults = styled.div` + margin-top: 2rem; + text-align: center; + font-size: 1rem; + color: #888; +`; +const InfoButton = styled.button` + cursor: pointer; + padding: 10px 4px 10px 4px; + display: block; + width: 100%; + color: inherit; + border-radius: 6px; + background-color: ${(props) => props.theme.bg}; + text-align: center; + font-size: 14px; + margin-bottom: 8px; + border:1px solid ${(props) => props.theme.secondary.text}; + +` +export const SearchBar = ({ + apiKey = "74039c6d-bff7-44ce-ae55-2973cbf13837", + apiHost = "https://gptcloud.arc53.com", + theme = "dark", + placeholder = "Search or Ask AI...", + width = "256px" +}: SearchBarProps) => { + const [input, setInput] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [isWidgetOpen, setIsWidgetOpen] = React.useState(false); + const inputRef = React.useRef(null); + const containerRef = React.useRef(null); + const [isResultVisible, setIsResultVisible] = React.useState(true); + const [results, setResults] = React.useState([]); + const debounceTimeout = React.useRef | null>(null); + const abortControllerRef = React.useRef(null) + const browserOS = getOS(); + function isTouchDevice() { + return 'ontouchstart' in window; + } + const isTouch = isTouchDevice(); + const getKeyboardInstruction = () => { + if (isResultVisible) return "Enter" + if (browserOS === 'mac') + return "⌘ K" + else + return "Ctrl K" + } + React.useEffect(() => { + const handleFocusSearch = (event: KeyboardEvent) => { + if ( + ((browserOS === 'win' || browserOS === 'linux') && event.ctrlKey && event.key === 'k') || + (browserOS === 'mac' && event.metaKey && event.key === 'k') + ) { + event.preventDefault(); + inputRef.current?.focus(); + } + } + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsResultVisible(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleFocusSearch); + return () => { + setIsResultVisible(true); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []) + React.useEffect(() => { + if (!input) { + setResults([]); + return; + } + setLoading(true); + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + debounceTimeout.current = setTimeout(() => { + getSearchResults(input, apiKey, apiHost, abortController.signal) + .then((data) => setResults(data)) + .catch((err) => console.log(err)) + .finally(() => setLoading(false)); + }, 500); + + return () => { + abortController.abort(); + clearTimeout(debounceTimeout.current ?? undefined); + }; + }, [input]) + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + openWidget(); + } + }; + const openWidget = () => { + setIsWidgetOpen(true); + setIsResultVisible(false) + } + const handleClose = () => { + setIsWidgetOpen(false); + } + const md = new MarkdownIt(); + return ( + +
+ + setIsResultVisible(true)} + ref={inputRef} + onSubmit={() => setIsWidgetOpen(true)} + onKeyDown={(e) => handleKeyDown(e)} + placeholder={placeholder} + value={input} + onChange={(e) => setInput(e.target.value)} + /> + { + input.length > 0 && isResultVisible && ( + + + { + isTouch ? + "Ask the AI" : + <> + Press Enter to ask the AI + + } + + {!loading ? + (results.length > 0 ? + results.map((res) => { + const containsSource = res.source !== 'local'; + return ( + { + if (!containsSource) return; + window.open(res.source, '_blank', 'noopener, noreferrer') + }} + className={containsSource ? "contains-source" : ""}> + {res.title} + + + + + ) + }) + : + No results + ) + : + + } + + ) + } + { + isTouch ? + + { + setIsWidgetOpen(true) + }} + title={"Tap to Ask the AI"}> + Tap + + : + + {getKeyboardInstruction()} + + } + + +
+
+ ) +} \ No newline at end of file diff --git a/extensions/react-widget/src/index.html b/extensions/react-widget/src/index.html index 0f0710d5d..40eaad152 100644 --- a/extensions/react-widget/src/index.html +++ b/extensions/react-widget/src/index.html @@ -9,11 +9,11 @@
- - --> diff --git a/extensions/react-widget/src/index.ts b/extensions/react-widget/src/index.ts index 1efa89a63..e29b85a52 100644 --- a/extensions/react-widget/src/index.ts +++ b/extensions/react-widget/src/index.ts @@ -1 +1,2 @@ -export { DocsGPTWidget } from "./components/DocsGPTWidget"; \ No newline at end of file +export {SearchBar} from "./components/SearchBar" +export { DocsGPTWidget } from "./components/DocsGPTWidget"; diff --git a/extensions/react-widget/src/main.tsx b/extensions/react-widget/src/main.tsx index 4fb3bbb4c..a8542e263 100644 --- a/extensions/react-widget/src/main.tsx +++ b/extensions/react-widget/src/main.tsx @@ -1,12 +1,25 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { DocsGPTWidget } from './components/DocsGPTWidget'; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import { DocsGPTWidget } from './components/DocsGPTWidget'; +import { SearchBar } from './components/SearchBar'; +import React from "react"; if (typeof window !== 'undefined') { const renderWidget = (elementId: string, props = {}) => { const root = createRoot(document.getElementById(elementId) as HTMLElement); root.render(); }; + const renderSearchBar = (elementId: string, props = {}) => { + const root = createRoot(document.getElementById(elementId) as HTMLElement); + root.render(); + }; (window as any).renderDocsGPTWidget = renderWidget; + + (window as any).renderSearchBar = renderSearchBar; } -export { DocsGPTWidget }; \ No newline at end of file +const container = document.getElementById("app") as HTMLElement; +const root = createRoot(container) +root.render(); + +export { DocsGPTWidget }; +export { SearchBar } diff --git a/extensions/react-widget/src/requests/searchAPI.ts b/extensions/react-widget/src/requests/searchAPI.ts new file mode 100644 index 000000000..7411a8103 --- /dev/null +++ b/extensions/react-widget/src/requests/searchAPI.ts @@ -0,0 +1,37 @@ +import { Result } from "@/types"; + +async function getSearchResults(question: string, apiKey: string, apiHost: string, signal: AbortSignal): Promise { + + const payload = { + question, + api_key: apiKey + }; + + try { + const response = await fetch(`${apiHost}/api/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: signal + }); + + if (!response.ok) { + throw new Error(`Error: ${response.status}`); + } + + const data: Result[] = await response.json(); + return data; + + } catch (error) { + if (!(error instanceof DOMException && error.name == "AbortError")) { + console.error("Failed to fetch documents:", error); + } + throw error; + } +} + +export { + getSearchResults +} \ No newline at end of file diff --git a/extensions/react-widget/src/types/index.ts b/extensions/react-widget/src/types/index.ts index 717efd924..cea9e43a3 100644 --- a/extensions/react-widget/src/types/index.ts +++ b/extensions/react-widget/src/types/index.ts @@ -32,5 +32,25 @@ export interface WidgetProps { buttonText?:string; buttonBg?:string; collectFeedback?:boolean; - deafultOpen?: boolean; -} \ No newline at end of file + defaultOpen?: boolean; +} +export interface WidgetCoreProps extends WidgetProps { + widgetRef?:React.RefObject | null; + handleClose?:React.MouseEventHandler | undefined; + isOpen:boolean; + prefilledQuery?: string; +} + +export interface SearchBarProps { + apiHost?: string; + apiKey?: string; + theme?:THEME; + placeholder?:string; + width?:string; +} + +export interface Result { + text:string; + title:string; + source:string; +} diff --git a/extensions/react-widget/src/utils/helper.ts b/extensions/react-widget/src/utils/helper.ts new file mode 100644 index 000000000..39c720e21 --- /dev/null +++ b/extensions/react-widget/src/utils/helper.ts @@ -0,0 +1,27 @@ +export const getOS = () => { + const platform = window.navigator.platform; + const userAgent = window.navigator.userAgent || window.navigator.vendor; + + if (/Mac/i.test(platform)) { + return 'mac'; + } + + if (/Win/i.test(platform)) { + return 'win'; + } + + if (/Linux/i.test(platform) && !/Android/i.test(userAgent)) { + return 'linux'; + } + + if (/Android/i.test(userAgent)) { + return 'android'; + } + + if (/iPhone|iPad|iPod/i.test(userAgent)) { + return 'ios'; + } + + return 'other'; + }; + \ No newline at end of file diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 688942617..3299f808f 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -332,6 +332,9 @@ function Upload({ ], 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpeg'], + 'image/jpg': ['.jpg'], }, });