fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
612 lines
17 KiB
TypeScript
612 lines
17 KiB
TypeScript
import React from 'react';
|
|
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
|
|
import { WidgetCore } from './DocsGPTWidget';
|
|
import { SearchBarProps } from '@/types';
|
|
import { getSearchResults } from '../requests/searchAPI';
|
|
import { Result } from '@/types';
|
|
import { getOS, processMarkdownString } from '../utils/helper';
|
|
import DOMPurify from 'dompurify';
|
|
import {
|
|
CodeIcon,
|
|
TextAlignLeftIcon,
|
|
HeadingIcon,
|
|
ReaderIcon,
|
|
ListBulletIcon,
|
|
QuoteIcon,
|
|
} from '@radix-ui/react-icons';
|
|
const themes = {
|
|
dark: {
|
|
name: 'dark',
|
|
bg: '#202124',
|
|
text: '#EDEDED',
|
|
primary: {
|
|
text: '#FAFAFA',
|
|
bg: '#111111',
|
|
},
|
|
secondary: {
|
|
text: '#A1A1AA',
|
|
bg: '#38383b',
|
|
},
|
|
},
|
|
light: {
|
|
name: 'light',
|
|
bg: '#EAEAEA',
|
|
text: '#171717',
|
|
primary: {
|
|
text: '#222327',
|
|
bg: '#fff',
|
|
},
|
|
secondary: {
|
|
text: '#A1A1AA',
|
|
bg: '#F6F6F6',
|
|
},
|
|
},
|
|
};
|
|
|
|
const GlobalStyle = createGlobalStyle`
|
|
.highlight {
|
|
color: ${(props) => (props.theme.name === 'dark' ? '#4B9EFF' : '#0066CC')};
|
|
font-weight: 500;
|
|
}
|
|
`;
|
|
|
|
const loadGeistFont = () => {
|
|
const link = document.createElement('link');
|
|
link.href =
|
|
'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap';
|
|
link.rel = 'stylesheet';
|
|
document.head.appendChild(link);
|
|
};
|
|
|
|
const Main = styled.div`
|
|
all: initial;
|
|
font-family: 'Geist', sans-serif;
|
|
`;
|
|
const SearchButton = styled.button<{ $inputWidth: string }>`
|
|
padding: 6px 6px;
|
|
font-family: inherit;
|
|
width: ${({ $inputWidth }) => $inputWidth};
|
|
border-radius: 8px;
|
|
display: inline;
|
|
color: ${(props) => props.theme.secondary.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;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
`;
|
|
|
|
const Container = styled.div`
|
|
position: relative;
|
|
display: inline-block;
|
|
`;
|
|
const SearchOverlay = styled.div`
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: #0000001a;
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
z-index: 99;
|
|
`;
|
|
|
|
const SearchResults = styled.div`
|
|
position: fixed;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: ${(props) =>
|
|
props.theme.name === 'dark'
|
|
? 'rgba(0, 0, 0, 0.15)'
|
|
: 'rgba(255, 255, 255, 0.4)'};
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
border-radius: 15px;
|
|
padding: 8px 0px 8px 0px;
|
|
width: 792px;
|
|
max-width: 90vw;
|
|
height: 396px;
|
|
z-index: 100;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: ${(props) => props.theme.primary.text};
|
|
|
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
|
backdrop-filter: blur(82px);
|
|
-webkit-backdrop-filter: blur(82px);
|
|
border-radius: 10px;
|
|
|
|
box-sizing: border-box;
|
|
|
|
@media only screen and (max-width: 768px) {
|
|
height: 80vh;
|
|
width: 90vw;
|
|
}
|
|
`;
|
|
|
|
const SearchResultsScroll = styled.div`
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
scrollbar-gutter: stable;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #383838 transparent;
|
|
padding: 0 16px;
|
|
`;
|
|
|
|
const IconTitleWrapper = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
|
|
.element-icon {
|
|
margin: 4px;
|
|
}
|
|
`;
|
|
|
|
const Title = styled.h3`
|
|
font-size: 15px;
|
|
font-weight: 400;
|
|
color: ${(props) => props.theme.primary.text};
|
|
margin: 0;
|
|
overflow-wrap: break-word;
|
|
white-space: normal;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
const ContentWrapper = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const ResultWrapper = styled.div`
|
|
display: flex;
|
|
align-items: flex-start;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
background-color: transparent;
|
|
font-family: 'Geist', sans-serif;
|
|
border-radius: 8px;
|
|
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
word-break: break-word;
|
|
white-space: normal;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
|
|
&:hover {
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
}
|
|
`;
|
|
|
|
const Content = styled.div`
|
|
display: flex;
|
|
margin-left: 8px;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding: 4px 0px 0px 12px;
|
|
font-size: 15px;
|
|
color: ${(props) => props.theme.primary.text};
|
|
line-height: 1.6;
|
|
border-left: 2px solid ${(props) => props.theme.primary.text}CC;
|
|
overflow: hidden;
|
|
`;
|
|
const ContentSegment = styled.div`
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding-right: 16px;
|
|
overflow-wrap: break-word;
|
|
white-space: normal;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
const Toolkit = styled.kbd`
|
|
position: absolute;
|
|
right: 4px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background-color: ${(props) => props.theme.primary.bg};
|
|
color: ${(props) => props.theme.secondary.text};
|
|
font-weight: 600;
|
|
font-size: 10px;
|
|
padding: 3px 6px;
|
|
border: 1px solid ${(props) => props.theme.secondary.text};
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
`;
|
|
const Loader = styled.div`
|
|
margin: 2rem auto;
|
|
border: 4px solid
|
|
${(props) =>
|
|
props.theme.name === 'dark'
|
|
? 'rgba(255, 255, 255, 0.2)'
|
|
: 'rgba(0, 0, 0, 0.1)'};
|
|
border-top: 4px solid
|
|
${(props) =>
|
|
props.theme.name === 'dark' ? '#FFFFFF' : 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: 14px;
|
|
color: ${(props) => (props.theme.name === 'dark' ? '#E0E0E0' : '#505050')};
|
|
font-weight: 500;
|
|
`;
|
|
const AskAIButton = styled.button`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 12px;
|
|
width: calc(100% - 32px);
|
|
margin: 0 16px 16px 16px;
|
|
box-sizing: border-box;
|
|
height: 50px;
|
|
padding: 8px 24px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: ${(props) => props.theme.text};
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
backdrop-filter: blur(16px);
|
|
-webkit-backdrop-filter: blur(16px);
|
|
background-color: ${(props) =>
|
|
props.theme.name === 'dark'
|
|
? 'rgba(255, 255, 255, 0.05)'
|
|
: 'rgba(0, 0, 0, 0.03)'};
|
|
|
|
&:hover {
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
background-color: ${(props) =>
|
|
props.theme.name === 'dark'
|
|
? 'rgba(255, 255, 255, 0.1)'
|
|
: 'rgba(0, 0, 0, 0.06)'};
|
|
}
|
|
`;
|
|
|
|
const SearchHeader = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid
|
|
${(props) =>
|
|
props.theme.name === 'dark' ? '#FFFFFF24' : 'rgba(0, 0, 0, 0.14)'};
|
|
`;
|
|
|
|
const TextField = styled.input`
|
|
width: calc(100% - 32px);
|
|
margin: 0 16px;
|
|
padding: 12px 16px;
|
|
border: none;
|
|
background-color: transparent;
|
|
color: ${(props) => props.theme.text};
|
|
font-size: 20px;
|
|
font-weight: 400;
|
|
outline: none;
|
|
|
|
&:focus {
|
|
border-color: none;
|
|
}
|
|
|
|
&::placeholder {
|
|
color: ${(props) =>
|
|
props.theme.name === 'dark'
|
|
? 'rgba(255, 255, 255, 0.6)'
|
|
: 'rgba(0, 0, 0, 0.5)'} !important;
|
|
opacity: 100%; /* Force opacity to ensure placeholder is visible */
|
|
font-weight: 500;
|
|
}
|
|
`;
|
|
|
|
const EscapeInstruction = styled.kbd`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 12px 16px 0;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
background-color: transparent;
|
|
border: 1px solid
|
|
${(props) =>
|
|
props.theme.name === 'dark'
|
|
? 'rgba(237, 237, 237, 0.6)'
|
|
: 'rgba(23, 23, 23, 0.6)'};
|
|
color: ${(props) => (props.theme.name === 'dark' ? '#EDEDED' : '#171717')};
|
|
font-size: 12px;
|
|
font-family: 'Geist', sans-serif;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
width: fit-content;
|
|
-webkit-appearance: none;
|
|
-moz-appearance: none;
|
|
appearance: none;
|
|
`;
|
|
|
|
export const SearchBar = ({
|
|
apiKey = '74039c6d-bff7-44ce-ae55-2973cbf13837',
|
|
apiHost = 'https://gptcloud.arc53.com',
|
|
theme = 'dark',
|
|
placeholder = 'Search or Ask AI...',
|
|
width = '256px',
|
|
buttonText = 'Search here',
|
|
}: 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>(false);
|
|
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();
|
|
const isTouch = 'ontouchstart' in window;
|
|
|
|
const getKeyboardInstruction = () => {
|
|
if (isResultVisible) return 'Enter';
|
|
return browserOS === 'mac' ? '⌘ + K' : 'Ctrl + K';
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
loadGeistFont();
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsResultVisible(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
((browserOS === 'win' || browserOS === 'linux') &&
|
|
event.ctrlKey &&
|
|
event.key === 'k') ||
|
|
(browserOS === 'mac' && event.metaKey && event.key === 'k')
|
|
) {
|
|
event.preventDefault();
|
|
inputRef.current?.focus();
|
|
setIsResultVisible(true);
|
|
} else if (event.key === 'Escape') {
|
|
setIsResultVisible(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!input) {
|
|
setResults([]);
|
|
setLoading(false);
|
|
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) => !abortController.signal.aborted && 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);
|
|
setIsResultVisible(true);
|
|
};
|
|
|
|
return (
|
|
<ThemeProvider theme={{ ...themes[theme] }}>
|
|
<Main>
|
|
<GlobalStyle />
|
|
<Container ref={containerRef}>
|
|
<SearchButton
|
|
onClick={() => setIsResultVisible(true)}
|
|
$inputWidth={width}
|
|
>
|
|
{buttonText}
|
|
</SearchButton>
|
|
{isResultVisible && (
|
|
<>
|
|
<SearchOverlay onClick={() => setIsResultVisible(false)} />
|
|
<SearchResults>
|
|
<SearchHeader>
|
|
<TextField
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => handleKeyDown(e)}
|
|
placeholder={placeholder}
|
|
autoFocus
|
|
/>
|
|
<EscapeInstruction onClick={() => setIsResultVisible(false)}>
|
|
Esc
|
|
</EscapeInstruction>
|
|
</SearchHeader>
|
|
<AskAIButton onClick={openWidget}>
|
|
<img
|
|
src="https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"
|
|
alt="DocsGPT"
|
|
width={24}
|
|
height={24}
|
|
/>
|
|
<span>Ask the AI</span>
|
|
</AskAIButton>
|
|
<SearchResultsScroll>
|
|
{!loading ? (
|
|
results.length > 0 ? (
|
|
results.map((res, key) => {
|
|
const containsSource = res.source !== 'local';
|
|
const processedResults = processMarkdownString(
|
|
res.text,
|
|
input,
|
|
);
|
|
if (processedResults)
|
|
return (
|
|
<ResultWrapper
|
|
key={key}
|
|
onClick={() => {
|
|
if (!containsSource) return;
|
|
window.open(
|
|
res.source,
|
|
'_blank',
|
|
'noopener, noreferrer',
|
|
);
|
|
}}
|
|
>
|
|
<div style={{ flex: 1 }}>
|
|
<ContentWrapper>
|
|
<IconTitleWrapper>
|
|
<ReaderIcon className="title-icon" />
|
|
<Title>{res.title}</Title>
|
|
</IconTitleWrapper>
|
|
<Content>
|
|
{processedResults.map((element, index) => (
|
|
<ContentSegment key={index}>
|
|
<IconTitleWrapper>
|
|
{element.tag === 'code' && (
|
|
<CodeIcon className="element-icon" />
|
|
)}
|
|
{(element.tag === 'bulletList' ||
|
|
element.tag === 'numberedList') && (
|
|
<ListBulletIcon className="element-icon" />
|
|
)}
|
|
{element.tag === 'text' && (
|
|
<TextAlignLeftIcon className="element-icon" />
|
|
)}
|
|
{element.tag === 'heading' && (
|
|
<HeadingIcon className="element-icon" />
|
|
)}
|
|
{element.tag === 'blockquote' && (
|
|
<QuoteIcon className="element-icon" />
|
|
)}
|
|
</IconTitleWrapper>
|
|
<div
|
|
style={{ flex: 1 }}
|
|
dangerouslySetInnerHTML={{
|
|
__html: DOMPurify.sanitize(
|
|
element.content,
|
|
),
|
|
}}
|
|
/>
|
|
</ContentSegment>
|
|
))}
|
|
</Content>
|
|
</ContentWrapper>
|
|
</div>
|
|
</ResultWrapper>
|
|
);
|
|
return null;
|
|
})
|
|
) : (
|
|
<NoResults>No results found</NoResults>
|
|
)
|
|
) : (
|
|
<Loader />
|
|
)}
|
|
</SearchResultsScroll>
|
|
</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>
|
|
);
|
|
};
|