Merge pull request #1436 from ManishMadan2882/main

React Widget: Search bar component
This commit is contained in:
Alex
2024-11-22 13:40:50 +00:00
committed by GitHub
17 changed files with 854 additions and 255 deletions

View File

@@ -339,6 +339,9 @@ class UploadFile(Resource):
".json",
".xlsx",
".pptx",
".png",
".jpg",
".jpeg",
],
job_name,
final_filename,
@@ -365,6 +368,9 @@ class UploadFile(Resource):
".json",
".xlsx",
".pptx",
".png",
".jpg",
".jpeg",
],
job_name,
final_filename,

View File

@@ -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

View File

@@ -13,6 +13,7 @@ from application.parser.file.rst_parser import RstParser
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 @@ DEFAULT_FILE_EXTRACTOR: Dict[str, BaseParser] = {
".mdx": MarkdownParser(),
".json":JSONParser(),
".pptx":PPTXParser(),
".png": ImageParser(),
".jpg": ImageParser(),
".jpeg": ImageParser(),
}

View File

@@ -7,7 +7,8 @@ from pathlib import Path
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 @@ class PDFParser(BaseParser):
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:

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,11 +1,11 @@
import React from "react"
import {DocsGPTWidget} from "./components/DocsGPTWidget"
const App = () => {
import {SearchBar} from "./components/SearchBar"
export const App = () => {
return (
<div>
<SearchBar/>
<DocsGPTWidget/>
</div>
)
}
export default App
}

View File

@@ -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,7 +49,86 @@ const sizesConfig = {
maxHeight: custom.maxHeight || '70vh',
}),
};
const createBox = keyframes`
0% {
transform: scale(0.6);
}
90% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
`
const closeBox = keyframes`
0% {
transform: scale(1);
}
10% {
transform: scale(1.02);
}
100% {
transform: scale(0.6);
}
`
const openContainer = keyframes`
0% {
width: 200px;
height: 100px;
}
100% {
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
border-radius: 12px;
}`
const closeContainer = keyframes`
0% {
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
border-radius: 12px;
}
100% {
width: 200px;
height: 100px;
}
`
const fadeIn = keyframes`
from {
opacity: 0;
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
}
`
const fadeOut = keyframes`
from {
opacity: 1;
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
}
to {
opacity: 0;
transform: scale(0.9);
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;
@@ -60,53 +139,35 @@ const Overlay = styled.div`
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;
z-index: 1001;
transform-origin:100% 100%;
display: block;
&.modal{
transform : translate(50%,50%);
}
&.open {
animation: createBox 250ms cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
animation: css ${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;
animation: css ${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% {
transform: scale(0.6);
}
90% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
@keyframes closeBox {
0% {
transform: scale(1);
}
10% {
transform: scale(1.02);
}
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};
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height} ;
position: relative;
flex-direction: column;
justify-content: space-between;
@@ -119,68 +180,20 @@ const StyledContainer = styled.div<{ isOpen: boolean }>`
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 {
0% {
width: 200px;
height: 100px;
}
100% {
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
border-radius: 12px;
}
}
@keyframes closeContainer {
0% {
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
border-radius: 12px;
}
100% {
width: 200px;
height: 100px;
}
}
@keyframes fadeIn {
from {
opacity: 0;
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
}
}
@keyframes fadeOut {
from {
opacity: 1;
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
}
to {
opacity: 0;
transform: scale(0.9);
width: ${(props) => props.theme.dimensions.width};
height: ${(props) => props.theme.dimensions.height};
}
}
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
</HeroContainer>
);
};
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<boolean>(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 (
<>
<FloatingButton bgcolor={buttonBg} onClick={handleOpen} hidden={!isFloatingButtonVisible} isAnimatingButton={isAnimatingButton}>
<img width={24} src={buttonIcon} />
<span>{buttonText}</span>
</FloatingButton>
<WidgetCore isOpen={open} handleClose={handleClose} {...coreProps} />
</>
)
}
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<string>("");
const [mounted, setMounted] = React.useState(false);
const [status, setStatus] = React.useState<Status>('idle');
const [queries, setQueries] = React.useState<Query[]>([])
const [conversationId, setConversationId] = React.useState<string | null>(null)
const [open, setOpen] = React.useState<boolean>(deafultOpen)
const [queries, setQueries] = React.useState<Query[]>([]);
const [conversationId, setConversationId] = React.useState<string | null>(null);
const [eventInterrupt, setEventInterrupt] = React.useState<boolean>(false); //click or scroll by user while autoScrolling
const [isAnimatingButton, setIsAnimatingButton] = React.useState(false);
const [isFloatingButtonVisible, setIsFloatingButtonVisible] = React.useState(true);
const isBubbleHovered = useRef<boolean>(false)
const widgetRef = useRef<HTMLDivElement>(null)
const isBubbleHovered = useRef<boolean>(false);
const endMessageRef = React.useRef<HTMLDivElement | null>(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<HTMLFormElement>) => {
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<HTMLImageElement, Event>) => {
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 (
<ThemeProvider theme={{ ...themes[theme], dimensions }}>
{open && size === 'large' &&
{isOpen && size === 'large' &&
<Overlay onClick={handleClose} />
}
<FloatingButton bgcolor={buttonBg} onClick={handleOpen} hidden={!isFloatingButtonVisible} isAnimatingButton={isAnimatingButton}>
<img width={24} src={buttonIcon} />
<span>{buttonText}</span>
</FloatingButton>
<WidgetContainer ref={widgetRef} className={`${size != "large" && (open ? "open" : "close")}`} modal={size == 'large'}>
{<StyledContainer isOpen={open}>
<div>
<CancelButton onClick={handleClose}>
<Cross2Icon width={24} height={24} color={theme === 'light' ? 'black' : 'white'} />
</CancelButton>
<Header>
<img style={{ transform: 'translateY(-5px)', maxWidth: "42px", maxHeight: "42px" }} onError={handleImageError} src={avatar} alt='docs-gpt' />
<ContentWrapper>
<Title>{title}</Title>
<Description>{description}</Description>
</ContentWrapper>
</Header>
</div>
<Conversation onWheel={handleUserInterrupt} onTouchMove={handleUserInterrupt}>
{
queries.length > 0 ? queries?.map((query, index) => {
return (
<React.Fragment key={index}>
{
query.prompt && <MessageBubble type='QUESTION'>
<Message
type='QUESTION'
ref={(!(query.response || query.error) && index === queries.length - 1) ? endMessageRef : null}>
{query.prompt}
</Message>
</MessageBubble>
}
{
query.response ? <MessageBubble onMouseOver={() => { isBubbleHovered.current = true }} type='ANSWER'>
<Message
type='ANSWER'
ref={(index === queries.length - 1) ? endMessageRef : null}
>
<Markdown
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(md.render(query.response)) }}
/>
</Message>
{(
<WidgetContainer className={`${size !== 'large' ? (isOpen ? "open" : "close") : "modal"}`} modal={size === 'large'}>
<StyledContainer isOpen={isOpen}>
<div>
<CancelButton onClick={handleClose}>
<Cross2Icon width={24} height={24} color={theme === 'light' ? 'black' : 'white'} />
</CancelButton>
<Header>
<img style={{ transform: 'translateY(-5px)', maxWidth: "42px", maxHeight: "42px" }} onError={handleImageError} src={avatar} alt='docs-gpt' />
<ContentWrapper>
<Title>{title}</Title>
<Description>{description}</Description>
</ContentWrapper>
</Header>
</div>
<Conversation onWheel={handleUserInterrupt} onTouchMove={handleUserInterrupt}>
{
queries.length > 0 ? queries?.map((query, index) => {
return (
<React.Fragment key={index}>
{
query.prompt && <MessageBubble type='QUESTION'>
<Message
type='QUESTION'
ref={(!(query.response || query.error) && index === queries.length - 1) ? endMessageRef : null}>
{query.prompt}
</Message>
</MessageBubble>
}
{
query.response ? <MessageBubble onMouseOver={() => { isBubbleHovered.current = true }} type='ANSWER'>
<Message
type='ANSWER'
ref={(index === queries.length - 1) ? endMessageRef : null}
>
<Markdown
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(md.render(query.response)) }}
/>
</Message>
{collectFeedback &&
<Feedback>
<Like
style={{
stroke: query.feedback == 'LIKE' ? '#8860DB' : '#c0c0c0',
visibility: query.feedback == 'LIKE' ? 'visible' : 'hidden'
}}
fill='none'
onClick={() => handleFeedback("LIKE", index)} />
<Dislike
style={{
stroke: query.feedback == 'DISLIKE' ? '#ed8085' : '#c0c0c0',
visibility: query.feedback == 'DISLIKE' ? 'visible' : 'hidden'
}}
fill='none'
onClick={() => handleFeedback("DISLIKE", index)} />
</Feedback>}
</MessageBubble>
: <div>
{
query.error ? <ErrorAlert>
{collectFeedback &&
<Feedback>
<Like
style={{
stroke: query.feedback == 'LIKE' ? '#8860DB' : '#c0c0c0',
visibility: query.feedback == 'LIKE' ? 'visible' : 'hidden'
}}
fill='none'
onClick={() => handleFeedback("LIKE", index)} />
<Dislike
style={{
stroke: query.feedback == 'DISLIKE' ? '#ed8085' : '#c0c0c0',
visibility: query.feedback == 'DISLIKE' ? 'visible' : 'hidden'
}}
fill='none'
onClick={() => handleFeedback("DISLIKE", index)} />
</Feedback>}
</MessageBubble>
: <div>
{
query.error ? <ErrorAlert>
<ExclamationTriangleIcon width={22} height={22} color='#b91c1c' />
<div>
<h5 style={{ margin: 2 }}>Network Error</h5>
<span style={{ margin: 2, fontSize: '13px' }}>{query.error}</span>
</div>
</ErrorAlert>
: <MessageBubble type='ANSWER'>
<Message type='ANSWER' style={{ fontWeight: 600 }}>
<DotAnimation>.</DotAnimation>
<Delay delay={200}>.</Delay>
<Delay delay={400}>.</Delay>
</Message>
</MessageBubble>
}
</div>
}
</React.Fragment>)
})
: <Hero title={heroTitle} description={heroDescription} theme={theme} />
}
</Conversation>
<div>
<PromptContainer
onSubmit={handleSubmit}>
<StyledInput
value={prompt} onChange={(event) => setPrompt(event.target.value)}
type='text' placeholder="Ask your question" />
<StyledButton
disabled={prompt.trim().length == 0 || status !== 'idle'}>
<PaperPlaneIcon width={18} height={18} color='white' />
</StyledButton>
</PromptContainer>
<Tagline>
Powered by&nbsp;
<Hyperlink target='_blank' href='https://www.docsgpt.cloud/'>DocsGPT</Hyperlink>
</Tagline>
</div>
</StyledContainer>}
</WidgetContainer>
<ExclamationTriangleIcon width={22} height={22} color='#b91c1c' />
<div>
<h5 style={{ margin: 2 }}>Network Error</h5>
<span style={{ margin: 2, fontSize: '13px' }}>{query.error}</span>
</div>
</ErrorAlert>
: <MessageBubble type='ANSWER'>
<Message type='ANSWER' style={{ fontWeight: 600 }}>
<DotAnimation>.</DotAnimation>
<Delay delay={200}>.</Delay>
<Delay delay={400}>.</Delay>
</Message>
</MessageBubble>
}
</div>
}
</React.Fragment>)
})
: <Hero title={heroTitle} description={heroDescription} theme={theme} />
}
</Conversation>
<div>
<PromptContainer
onSubmit={handleSubmit}>
<StyledInput
autoFocus
value={prompt} onChange={(event) => setPrompt(event.target.value)}
type='text' placeholder="Ask your question" />
<StyledButton
disabled={prompt.trim().length == 0 || status !== 'idle'}>
<PaperPlaneIcon width={18} height={18} color='white' />
</StyledButton>
</PromptContainer>
<Tagline>
Powered by&nbsp;
<Hyperlink target='_blank' href='https://www.docsgpt.cloud/'>DocsGPT</Hyperlink>
</Tagline>
</div>
</StyledContainer>
</WidgetContainer>
)
}
</ThemeProvider>
)
}

View File

@@ -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<string>("");
const [loading, setLoading] = React.useState<boolean>(false);
const [isWidgetOpen, setIsWidgetOpen] = React.useState<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLInputElement>(null);
const [isResultVisible, setIsResultVisible] = React.useState<boolean>(true);
const [results, setResults] = React.useState<Result[]>([]);
const debounceTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = React.useRef<AbortController | null>(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<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
openWidget();
}
};
const openWidget = () => {
setIsWidgetOpen(true);
setIsResultVisible(false)
}
const handleClose = () => {
setIsWidgetOpen(false);
}
const md = new MarkdownIt();
return (
<ThemeProvider theme={{ ...themes[theme] }}>
<Main>
<Container ref={containerRef}>
<TextField
spellCheck={false}
inputWidth={width}
onFocus={() => 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 && (
<SearchResults>
<InfoButton onClick={openWidget}>
{
isTouch ?
"Ask the AI" :
<>
Press <span style={{ fontSize: "16px" }}>&crarr;</span> Enter to ask the AI
</>
}
</InfoButton>
{!loading ?
(results.length > 0 ?
results.map((res) => {
const containsSource = res.source !== 'local';
return (
<ResultWrapper
onClick={() => {
if (!containsSource) return;
window.open(res.source, '_blank', 'noopener, noreferrer')
}}
className={containsSource ? "contains-source" : ""}>
<Title>{res.title}</Title>
<Content>
<Markdown
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(md.render((res.text).substring(0, 256) + "...")) }}
/>
</Content>
</ResultWrapper>
)
})
:
<NoResults>No results</NoResults>
)
:
<Loader />
}
</SearchResults>
)
}
{
isTouch ?
<Toolkit
onClick={() => {
setIsWidgetOpen(true)
}}
title={"Tap to Ask the AI"}>
Tap
</Toolkit>
:
<Toolkit
title={getKeyboardInstruction() === "Enter" ? "Press Enter to Ask AI" : ""}>
{getKeyboardInstruction()}
</Toolkit>
}
</Container>
<WidgetCore
theme={theme}
apiHost={apiHost}
apiKey={apiKey}
prefilledQuery={input}
isOpen={isWidgetOpen}
handleClose={handleClose} size={"large"}
/>
</Main>
</ThemeProvider>
)
}

View File

@@ -9,11 +9,11 @@
<body>
<div id="app"></div>
<script type="module" src="main.tsx"></script>
<script type="module" src="../dist/main.js"></script>
<script type="module">
<!-- <script type="module">
window.onload = function() {
renderDocsGPTWidget('app');
renderSearchBar('app')
}
</script>
</script> -->
</body>
</html>

View File

@@ -1 +1,2 @@
export { DocsGPTWidget } from "./components/DocsGPTWidget";
export {SearchBar} from "./components/SearchBar"
export { DocsGPTWidget } from "./components/DocsGPTWidget";

View File

@@ -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(<DocsGPTWidget {...props} />);
};
const renderSearchBar = (elementId: string, props = {}) => {
const root = createRoot(document.getElementById(elementId) as HTMLElement);
root.render(<SearchBar {...props} />);
};
(window as any).renderDocsGPTWidget = renderWidget;
(window as any).renderSearchBar = renderSearchBar;
}
export { DocsGPTWidget };
const container = document.getElementById("app") as HTMLElement;
const root = createRoot(container)
root.render(<App />);
export { DocsGPTWidget };
export { SearchBar }

View File

@@ -0,0 +1,37 @@
import { Result } from "@/types";
async function getSearchResults(question: string, apiKey: string, apiHost: string, signal: AbortSignal): Promise<Result[]> {
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
}

View File

@@ -32,5 +32,25 @@ export interface WidgetProps {
buttonText?:string;
buttonBg?:string;
collectFeedback?:boolean;
deafultOpen?: boolean;
}
defaultOpen?: boolean;
}
export interface WidgetCoreProps extends WidgetProps {
widgetRef?:React.RefObject<HTMLDivElement> | 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;
}

View File

@@ -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';
};

View File

@@ -332,6 +332,9 @@ function Upload({
],
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
['.pptx'],
'image/png': ['.png'],
'image/jpeg': ['.jpeg'],
'image/jpg': ['.jpg'],
},
});