mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
Merge pull request #2782 from ManishMadan2882/main
feat(widget): align search bar with widget theme, add voice search
This commit is contained in:
12 files changed
+506
-440
No files matched your search
@@ -98,9 +98,11 @@ The DocsGPT Search Bar Widget offers a range of customizable properties that all
|
||||
|-----------------|-----------|-------------------------------------|--------------------------------------------------------------------------------------------------|
|
||||
| **`apiKey`** | `string` | `"your-api-key"` | API key for authentication with your DocsGPT API. Leave empty if no authentication is required. |
|
||||
| **`apiHost`** | `string` | `"https://gptcloud.arc53.com"` | **Required.** The URL of your DocsGPT API backend. This endpoint handles vector similarity search queries. |
|
||||
| **`theme`** | `"dark" \| "light"` | `"dark"` | Color theme of the search bar. Options: `"dark"` or `"light"`. Defaults to `"dark"`. |
|
||||
| **`theme`** | `"dark" \| "light"` | `"dark"` | Color theme of the search bar and of the chat it opens. Options: `"dark"` or `"light"`. Defaults to `"dark"`. |
|
||||
| **`placeholder`** | `string` | `"Search or Ask AI..."` | Placeholder text displayed in the search input field. |
|
||||
| **`width`** | `string` | `"256px"` | Width of the search bar. Accepts any valid CSS width value (e.g., `"300px"`, `"100%"`, `"20rem"`). |
|
||||
| **`allowedFileExtensions`** | `string[]` | _unset_ | Passed to the chat opened from "Ask the AI". File extensions its composer accepts, e.g. `['.pdf', '.md']`; attachments stay off while unset. See the [chat widget page](/Extensions/chat-widget). |
|
||||
| **`showMicButton`** | `boolean` | `false` | Adds a microphone to the search field for dictating a query, and passes the same option to the chat opened from "Ask the AI". Uses the browser's Web Speech API. See the [chat widget page](/Extensions/chat-widget). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -172,6 +172,8 @@ import { SearchBar } from "docsgpt-react";
|
||||
| **`theme`** | `"dark" \| "light"` | `"dark"` | The theme of the search bar. Accepts `"dark"` or `"light"`. |
|
||||
| **`placeholder`** | `string` | `"Search or Ask AI..."` | Placeholder text displayed in the search input field. |
|
||||
| **`width`** | `string` | `"256px"` | Width of the search bar. Accepts any valid CSS width value (e.g., `"300px"`, `"100%"`, `"20rem"`). |
|
||||
| **`allowedFileExtensions`** | `string[]` | _unset_ | Passed to the chat opened from "Ask the AI". File extensions its composer accepts, e.g. `['.pdf', '.md']`; attachments stay off while unset. |
|
||||
| **`showMicButton`** | `boolean` | `false` | Adds a microphone to the search field for dictating a query, and passes the same option to the chat opened from "Ask the AI". Uses the browser's Web Speech API. |
|
||||
|
||||
|
||||
Feel free to reach out if you need help customizing or extending the `SearchBar`!
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SearchBar } from './components/SearchBar';
|
||||
export const App = () => {
|
||||
return (
|
||||
<div>
|
||||
<SearchBar />
|
||||
<SearchBar showMicButton/>
|
||||
<DocsGPTWidget
|
||||
allowedFileExtensions={['.pdf', '.md', '.txt', '.docx', '.png', '.jpg']}
|
||||
showMicButton
|
||||
|
||||
@@ -109,8 +109,8 @@ const spin = keyframes`
|
||||
`;
|
||||
|
||||
/**
|
||||
* Determinate while bytes move, indeterminate while the server parses: that
|
||||
* phase has no percentage, and a bar frozen at 100% reads as a hang.
|
||||
* Determinate while uploading; indeterminate while the server parses, since
|
||||
* parsing reports no progress.
|
||||
*/
|
||||
const ProgressRing = styled.svg<{ $indeterminate?: boolean }>`
|
||||
width: 14px;
|
||||
@@ -238,7 +238,6 @@ const ChipRemove = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
/** Chip status, for the hover title. */
|
||||
const statusLabel = (attachment: Attachment): string => {
|
||||
if (attachment.status === 'uploading')
|
||||
return `Uploading ${attachment.progress}%`;
|
||||
@@ -254,8 +253,7 @@ export const AttachmentChips = ({
|
||||
attachments: Attachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}) => {
|
||||
// A touch user cannot see a tooltip, and a phone picker's unsupported
|
||||
// file lands here. Say why in the open.
|
||||
// Tooltips are unreachable on touch, so failure reasons are shown inline.
|
||||
const failures = attachments.filter(
|
||||
(attachment) => attachment.status === 'failed' && attachment.error,
|
||||
);
|
||||
@@ -322,12 +320,19 @@ export const ControlGroup = styled.div`
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const ControlButton = styled.button<{ $recording?: boolean }>`
|
||||
const ControlButton = styled.button<{
|
||||
$recording?: boolean;
|
||||
$iconOnly?: boolean;
|
||||
}>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
height: ${(props) => (props.$iconOnly ? '32px' : '28px')};
|
||||
padding: ${(props) => (props.$iconOnly ? '0' : '0 10px')};
|
||||
${(props) =>
|
||||
props.$iconOnly
|
||||
? 'width: 32px; flex-shrink: 0; justify-content: center;'
|
||||
: ''}
|
||||
border-radius: ${radii.full};
|
||||
border: 1px solid
|
||||
${(props) =>
|
||||
@@ -371,9 +376,7 @@ export const AttachButton = ({
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
// A button opening a hidden input, not a label wrapping one: a
|
||||
// `display: none` input takes no focus, so the label form is unreachable
|
||||
// from the keyboard.
|
||||
// A label wrapping a display:none input cannot be reached by keyboard.
|
||||
<ControlButton
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
@@ -404,16 +407,19 @@ export const MicButton = ({
|
||||
state,
|
||||
disabled,
|
||||
onClick,
|
||||
variant = 'pill',
|
||||
}: {
|
||||
state: MicButtonState;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
variant?: 'pill' | 'icon';
|
||||
}) => (
|
||||
<ControlButton
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || state === 'transcribing'}
|
||||
$recording={state === 'recording'}
|
||||
$iconOnly={variant === 'icon'}
|
||||
aria-label={MIC_TITLES[state]}
|
||||
title={MIC_TITLES[state]}
|
||||
>
|
||||
@@ -422,7 +428,7 @@ export const MicButton = ({
|
||||
) : (
|
||||
<MicIcon aria-hidden="true" />
|
||||
)}
|
||||
{MIC_LABELS[state]}
|
||||
{variant === 'pill' && MIC_LABELS[state]}
|
||||
</ControlButton>
|
||||
);
|
||||
|
||||
@@ -472,22 +478,21 @@ export const SentAttachments = ({
|
||||
</SentList>
|
||||
);
|
||||
|
||||
// One bar per animation frame, so the row holds about a second of history.
|
||||
// One bar per frame: roughly a second of history at 60fps.
|
||||
const WAVEFORM_BARS = 48;
|
||||
const BAR_GAP_RATIO = 0.4;
|
||||
// Speech peaks well below full scale; the floor keeps a quiet line visible.
|
||||
const LEVEL_GAIN = 2.8;
|
||||
const MIN_BAR_RATIO = 0.06;
|
||||
|
||||
const WaveformRow = styled.div`
|
||||
const WaveformRow = styled.div<{ $minHeight: string }>`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
min-height: ${(props) =>
|
||||
props.theme.dimensions!.size === 'large' ? '60px' : '40px'};
|
||||
min-height: ${(props) => props.$minHeight};
|
||||
`;
|
||||
|
||||
const WaveformCanvas = styled.canvas`
|
||||
@@ -505,16 +510,18 @@ const ListeningLabel = styled.span`
|
||||
`;
|
||||
|
||||
/**
|
||||
* Live microphone level, standing in for the input while dictation runs.
|
||||
* Canvas inside a rAF loop, because sixty React updates a second would
|
||||
* re-render the whole composer. A null analyser draws the resting line.
|
||||
* Live microphone level, drawn to canvas in a rAF loop so frames do not
|
||||
* re-render React. A null analyser draws a flat line.
|
||||
*/
|
||||
export const VoiceWaveform = ({
|
||||
analyserRef,
|
||||
label,
|
||||
minHeight = '40px',
|
||||
}: {
|
||||
analyserRef: React.RefObject<AnalyserNode | null>;
|
||||
label: string;
|
||||
/** Min height of the input it replaces, to avoid a layout shift. */
|
||||
minHeight?: string;
|
||||
}) => {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const theme = useTheme();
|
||||
@@ -543,7 +550,7 @@ export const VoiceWaveform = ({
|
||||
if (!samples || samples.length !== analyser.fftSize)
|
||||
samples = new Uint8Array(analyser.fftSize);
|
||||
analyser.getByteTimeDomainData(samples);
|
||||
// Time-domain bytes ride on 128; RMS of the deviation is the level.
|
||||
// Time-domain bytes are centred on 128.
|
||||
let sumSquares = 0;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const deviation = (samples[index] - 128) / 128;
|
||||
@@ -554,7 +561,7 @@ export const VoiceWaveform = ({
|
||||
levels.push(Math.min(1, level * LEVEL_GAIN));
|
||||
levels.shift();
|
||||
|
||||
// Re-read each frame: the panel resizes and the row reflows.
|
||||
// The panel can resize, so dimensions are read each frame.
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
@@ -592,7 +599,7 @@ export const VoiceWaveform = ({
|
||||
}, [analyserRef, barColor]);
|
||||
|
||||
return (
|
||||
<WaveformRow role="status" aria-label={label}>
|
||||
<WaveformRow role="status" aria-label={label} $minHeight={minHeight}>
|
||||
<WaveformCanvas ref={canvasRef} aria-hidden="true" />
|
||||
<ListeningLabel>{label}</ListeningLabel>
|
||||
</WaveformRow>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
normalizeExtensions,
|
||||
useAttachments,
|
||||
} from '../hooks/useAttachments';
|
||||
import { useVoiceInput, voiceInputSupported } from '../hooks/useVoiceInput';
|
||||
import { useDictation } from '../hooks/useDictation';
|
||||
import {
|
||||
AttachButton,
|
||||
AttachmentChips,
|
||||
@@ -35,12 +35,11 @@ import {
|
||||
DropOverlay,
|
||||
DropTarget,
|
||||
MicButton,
|
||||
type MicButtonState,
|
||||
SentAttachments,
|
||||
VoiceWaveform,
|
||||
} from './ComposerControls';
|
||||
import { DEFAULT_AVATAR } from './defaultAvatar';
|
||||
import { radii } from './tokens';
|
||||
import { radii, themes } from './tokens';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import {
|
||||
@@ -181,69 +180,6 @@ const ArrowDownIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const themes = {
|
||||
dark: {
|
||||
bg: '#222327',
|
||||
text: '#fff',
|
||||
primary: {
|
||||
text: '#FAFAFA',
|
||||
bg: '#222327',
|
||||
},
|
||||
secondary: {
|
||||
text: '#A1A1AA',
|
||||
bg: '#33343A',
|
||||
},
|
||||
shimmer: {
|
||||
base: '#A1A1AA',
|
||||
highlight: '#FAFAFA',
|
||||
},
|
||||
accent: {
|
||||
base: '#8860DB',
|
||||
hover: '#9B7BE4',
|
||||
strong: '#6D42C5',
|
||||
contrast: '#FFFFFF',
|
||||
soft: 'rgba(136, 96, 219, 0.18)',
|
||||
link: '#A78BFA',
|
||||
},
|
||||
hairline: 'rgba(255, 255, 255, 0.08)',
|
||||
danger: {
|
||||
text: '#F87171',
|
||||
soft: 'rgba(248, 113, 113, 0.10)',
|
||||
border: 'rgba(248, 113, 113, 0.32)',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
bg: '#fff',
|
||||
text: '#000',
|
||||
primary: {
|
||||
text: '#222327',
|
||||
bg: '#fff',
|
||||
},
|
||||
secondary: {
|
||||
text: '#71717A',
|
||||
bg: '#F4F4F5',
|
||||
},
|
||||
shimmer: {
|
||||
base: '#71717A',
|
||||
highlight: '#D4D4D8',
|
||||
},
|
||||
accent: {
|
||||
base: '#8860DB',
|
||||
hover: '#7A4FD0',
|
||||
strong: '#6D42C5',
|
||||
contrast: '#FFFFFF',
|
||||
soft: 'rgba(136, 96, 219, 0.12)',
|
||||
link: '#6D42C5',
|
||||
},
|
||||
hairline: 'rgba(0, 0, 0, 0.08)',
|
||||
danger: {
|
||||
text: '#B91C1C',
|
||||
soft: 'rgba(185, 28, 28, 0.06)',
|
||||
border: 'rgba(185, 28, 28, 0.24)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sizesConfig = {
|
||||
small: { size: 'small', width: '320px', height: '400px' },
|
||||
medium: { size: 'medium', width: '400px', height: '80vh' },
|
||||
@@ -656,28 +592,20 @@ const ActionButton = styled.button<{
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
css`
|
||||
color: ${
|
||||
props.$tone === 'danger'
|
||||
? props.theme.danger!.text
|
||||
: props.theme.accent!.base
|
||||
};
|
||||
background-color: ${
|
||||
props.$tone === 'danger'
|
||||
? props.theme.danger!.soft
|
||||
: props.theme.accent!.soft
|
||||
};
|
||||
color: ${props.$tone === 'danger'
|
||||
? props.theme.danger!.text
|
||||
: props.theme.accent!.base};
|
||||
background-color: ${props.$tone === 'danger'
|
||||
? props.theme.danger!.soft
|
||||
: props.theme.accent!.soft};
|
||||
|
||||
&:hover {
|
||||
color: ${
|
||||
props.$tone === 'danger'
|
||||
? props.theme.danger!.text
|
||||
: props.theme.accent!.base
|
||||
};
|
||||
background-color: ${
|
||||
props.$tone === 'danger'
|
||||
? props.theme.danger!.soft
|
||||
: props.theme.accent!.soft
|
||||
};
|
||||
color: ${props.$tone === 'danger'
|
||||
? props.theme.danger!.text
|
||||
: props.theme.accent!.base};
|
||||
background-color: ${props.$tone === 'danger'
|
||||
? props.theme.danger!.soft
|
||||
: props.theme.accent!.soft};
|
||||
}
|
||||
|
||||
svg {
|
||||
@@ -1188,7 +1116,6 @@ const HeroDescription = styled.p`
|
||||
padding: 0px;
|
||||
`;
|
||||
const Hyperlink = styled.a`
|
||||
/* Inherits the tagline colour; the underline is what marks it as a link. */
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
/* Keeps descenders clear of the rule at 11px. */
|
||||
@@ -1370,7 +1297,7 @@ export const WidgetCore = ({
|
||||
heroTitle = 'Welcome to DocsGPT !',
|
||||
heroDescription = 'This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources.',
|
||||
size = 'medium',
|
||||
theme = 'light',
|
||||
theme = 'dark',
|
||||
collectFeedback = true,
|
||||
isOpen = false,
|
||||
showSources = true,
|
||||
@@ -1400,12 +1327,10 @@ export const WidgetCore = ({
|
||||
const endMessageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const promptRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const attachmentInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
// The draft as it stood when dictation began; recognised words extend it.
|
||||
const voiceBaseRef = React.useRef('');
|
||||
// dragenter/dragleave fire per child crossed, hence a depth count.
|
||||
const dragDepthRef = React.useRef(0);
|
||||
|
||||
// The list doubles as the on switch: no accepted types, no attachments.
|
||||
// An empty list disables attachments.
|
||||
const acceptedExtensions = React.useMemo(
|
||||
() => normalizeExtensions(allowedFileExtensions),
|
||||
[allowedFileExtensions],
|
||||
@@ -1422,8 +1347,6 @@ export const WidgetCore = ({
|
||||
completed: completedAttachments,
|
||||
} = useAttachments({ apiKey, apiHost, acceptedExtensions });
|
||||
|
||||
// One place for the height arithmetic: typing, transcripts and sends all
|
||||
// go through here.
|
||||
const resizePrompt = React.useCallback(() => {
|
||||
const el = promptRef.current;
|
||||
if (!el) return;
|
||||
@@ -1436,42 +1359,31 @@ export const WidgetCore = ({
|
||||
)}px`;
|
||||
}, [size]);
|
||||
|
||||
// The textarea is controlled by `prompt`, so its value is the live draft.
|
||||
const handleVoiceStart = React.useCallback(() => {
|
||||
voiceBaseRef.current = promptRef.current?.value ?? '';
|
||||
}, []);
|
||||
const getPromptDraft = React.useCallback(
|
||||
() => promptRef.current?.value ?? '',
|
||||
[],
|
||||
);
|
||||
|
||||
const applyTranscript = React.useCallback(
|
||||
(text: string) => {
|
||||
// The base is fixed at the start, so revised interim words overwrite
|
||||
// only themselves and a typed half-question survives.
|
||||
const base = voiceBaseRef.current;
|
||||
setPrompt(base.trim() ? `${base.replace(/\s+$/, '')}\n${text}` : text);
|
||||
const applyDraft = React.useCallback(
|
||||
(value: string) => {
|
||||
setPrompt(value);
|
||||
// The height can only be recomputed once React has committed.
|
||||
window.requestAnimationFrame(resizePrompt);
|
||||
},
|
||||
[resizePrompt],
|
||||
);
|
||||
|
||||
const handleVoiceEnd = React.useCallback(() => {
|
||||
// Once at the end, not on every interim word.
|
||||
const focusPrompt = React.useCallback(() => {
|
||||
window.requestAnimationFrame(() => promptRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const {
|
||||
recordingState,
|
||||
error: voiceError,
|
||||
toggle: toggleVoiceInput,
|
||||
clearError: clearVoiceError,
|
||||
analyserRef: voiceAnalyserRef,
|
||||
} = useVoiceInput({
|
||||
onStart: handleVoiceStart,
|
||||
onTranscript: applyTranscript,
|
||||
onEnd: handleVoiceEnd,
|
||||
const dictation = useDictation({
|
||||
enabled: showMicButton,
|
||||
getDraft: getPromptDraft,
|
||||
onDraftChange: applyDraft,
|
||||
separator: '\n',
|
||||
onEnd: focusPrompt,
|
||||
});
|
||||
|
||||
// Firefox has no SpeechRecognition, nor does an insecure origin.
|
||||
const canUseVoice = showMicButton && voiceInputSupported();
|
||||
const md = new MarkdownIt();
|
||||
//Custom markdown for the table
|
||||
md.renderer.rules.table_open = () =>
|
||||
@@ -1738,8 +1650,7 @@ export const WidgetCore = ({
|
||||
if (status === 'loading') return;
|
||||
const prompt = queries[index]?.prompt;
|
||||
if (!prompt) return;
|
||||
// The composer list was cleared on send, so the row's ids are the only
|
||||
// record of what the question carried.
|
||||
// The composer list is cleared on send, so retry reuses the row's ids.
|
||||
const attached = queries[index]?.attachments;
|
||||
setQueries((prev: Query[]) => {
|
||||
const updated = [...prev];
|
||||
@@ -1753,8 +1664,8 @@ export const WidgetCore = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Pending and failed attachments both hold the send, so neither is
|
||||
// silently dropped. Pending reads first: it clears on its own.
|
||||
// Pending and failed attachments both block sending. The pending note
|
||||
// takes precedence because it clears on its own.
|
||||
const pendingNote =
|
||||
pendingCount > 0
|
||||
? `Waiting for ${pendingCount} file${pendingCount === 1 ? '' : 's'} to finish\u2026`
|
||||
@@ -1764,8 +1675,7 @@ export const WidgetCore = ({
|
||||
? 'Remove the file that could not be attached, then send.'
|
||||
: null;
|
||||
const sendBlockedReason = pendingNote ?? failedNote;
|
||||
const isDictating =
|
||||
recordingState === 'recording' || recordingState === 'transcribing';
|
||||
const isDictating = dictation.isDictating;
|
||||
const canSubmit =
|
||||
prompt.trim().length > 0 &&
|
||||
!sendBlockedReason &&
|
||||
@@ -1774,7 +1684,7 @@ export const WidgetCore = ({
|
||||
|
||||
const submitPrompt = async () => {
|
||||
if (!canSubmit) return;
|
||||
// Before the value clears, or the composer sits tall and empty a frame.
|
||||
// Reset first, so the empty composer does not render tall for a frame.
|
||||
if (promptRef.current) promptRef.current.style.height = 'auto';
|
||||
await appendQuery(prompt);
|
||||
};
|
||||
@@ -1802,7 +1712,7 @@ export const WidgetCore = ({
|
||||
) => {
|
||||
const value = event.target.value;
|
||||
// A stale voice error would hide the attachment notice below.
|
||||
if (voiceError) clearVoiceError();
|
||||
if (dictation.error) dictation.clearError();
|
||||
setPrompt(value);
|
||||
resizePrompt();
|
||||
if (value.includes('\n')) {
|
||||
@@ -1829,17 +1739,16 @@ export const WidgetCore = ({
|
||||
if (file) files.push(file);
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
// Or the file also lands in the textarea as binary noise.
|
||||
// Keeps the file from also being pasted as text.
|
||||
e.preventDefault();
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Otherwise the overlay flashes on every drag crossing the page.
|
||||
// Ignore drags that carry no files, such as selected text.
|
||||
const isFileDrag = (e: React.DragEvent) =>
|
||||
Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||||
|
||||
// A file dropped mid-dictation would queue behind the waveform, unseen
|
||||
// and unsendable until recording stops.
|
||||
// Files cannot be attached while dictating.
|
||||
const acceptsFiles = attachmentsEnabled && !isDictating;
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
@@ -1891,16 +1800,11 @@ export const WidgetCore = ({
|
||||
};
|
||||
|
||||
// Neither feature enabled keeps the original single-row composer.
|
||||
const hasComposerControls = attachmentsEnabled || canUseVoice;
|
||||
const hasComposerControls = attachmentsEnabled || dictation.available;
|
||||
|
||||
const micButtonState: MicButtonState =
|
||||
recordingState === 'recording' || recordingState === 'transcribing'
|
||||
? recordingState
|
||||
: 'idle';
|
||||
|
||||
// A failure to act on outranks a wait that clears itself.
|
||||
const composerNote = voiceError
|
||||
? { text: voiceError, tone: 'danger' as const }
|
||||
// Errors take precedence over the pending-upload note.
|
||||
const composerNote = dictation.error
|
||||
? { text: dictation.error, tone: 'danger' as const }
|
||||
: sendBlockedReason
|
||||
? {
|
||||
text: sendBlockedReason,
|
||||
@@ -2196,16 +2100,16 @@ export const WidgetCore = ({
|
||||
<PromptRow>
|
||||
{isDictating && (
|
||||
<VoiceWaveform
|
||||
analyserRef={voiceAnalyserRef}
|
||||
analyserRef={dictation.analyserRef}
|
||||
label={
|
||||
recordingState === 'recording'
|
||||
dictation.state === 'recording'
|
||||
? 'Listening\u2026'
|
||||
: 'Finishing\u2026'
|
||||
}
|
||||
minHeight={size === 'large' ? '60px' : '40px'}
|
||||
/>
|
||||
)}
|
||||
{/* Hidden, not unmounted: the ref stays live for reading
|
||||
the draft and refocusing afterwards. */}
|
||||
{/* Kept mounted so promptRef stays valid while dictating. */}
|
||||
<StyledTextarea
|
||||
$hidden={isDictating}
|
||||
id="chatInput"
|
||||
@@ -2234,15 +2138,14 @@ export const WidgetCore = ({
|
||||
disabled={isDictating}
|
||||
/>
|
||||
)}
|
||||
{canUseVoice && (
|
||||
{dictation.available && (
|
||||
<MicButton
|
||||
state={micButtonState}
|
||||
// Never while recording: it is the only way to
|
||||
// close the microphone.
|
||||
state={dictation.state}
|
||||
// Enabled while recording: it is how dictation stops.
|
||||
disabled={
|
||||
status === 'loading' && micButtonState === 'idle'
|
||||
status === 'loading' && dictation.state === 'idle'
|
||||
}
|
||||
onClick={toggleVoiceInput}
|
||||
onClick={dictation.toggle}
|
||||
/>
|
||||
)}
|
||||
</ControlGroup>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
|
||||
import styled, { ThemeProvider, keyframes } from 'styled-components';
|
||||
import { WidgetCore } from './DocsGPTWidget';
|
||||
import { DEFAULT_AVATAR } from './defaultAvatar';
|
||||
import { radii, themes } from './tokens';
|
||||
import { MicButton, VoiceWaveform } from './ComposerControls';
|
||||
import { useDictation } from '../hooks/useDictation';
|
||||
import { SearchBarProps } from '@/types';
|
||||
import { getSearchResults } from '../requests/searchAPI';
|
||||
import { Result } from '@/types';
|
||||
@@ -15,135 +18,107 @@ import {
|
||||
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 spin = keyframes`
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
`;
|
||||
|
||||
const SearchButton = styled.button<{ $inputWidth: string }>`
|
||||
padding: 6px 6px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
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;
|
||||
height: 36px;
|
||||
padding: 0 72px 0 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
background-color: ${(props) => props.theme.secondary.bg};
|
||||
border: 1px solid ${(props) => props.theme.hairline};
|
||||
border-radius: ${radii.sm};
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-color: ${(props) => props.theme.accent!.base};
|
||||
box-shadow: 0 0 0 3px ${(props) => props.theme.accent!.soft};
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const SearchResults = styled.div`
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 100;
|
||||
transform: translate(-50%, -50%);
|
||||
box-sizing: border-box;
|
||||
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%);
|
||||
padding: 8px 0;
|
||||
overflow: hidden;
|
||||
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;
|
||||
background-color: ${(props) => props.theme.primary.bg};
|
||||
border: 1px solid ${(props) => props.theme.hairline};
|
||||
border-radius: ${radii.panel};
|
||||
box-shadow:
|
||||
0 12px 44px rgba(0, 0, 0, 0.18),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
height: 80vh;
|
||||
width: 90vw;
|
||||
height: 80vh;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchResultsScroll = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #383838 transparent;
|
||||
padding: 0 16px;
|
||||
scrollbar-color: ${(props) => props.theme.hairline} transparent;
|
||||
`;
|
||||
|
||||
const IconTitleWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
|
||||
.element-icon {
|
||||
margin: 4px;
|
||||
@@ -151,208 +126,199 @@ const IconTitleWrapper = styled.div`
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
box-sizing: border-box;
|
||||
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;
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: ${radii.sm};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background-color: ${(props) => props.theme.secondary.bg};
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
gap: 6px;
|
||||
margin-left: 7px;
|
||||
padding: 2px 0 0 14px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
border-left: 2px solid ${(props) => props.theme.hairline};
|
||||
|
||||
/* Scoped so the host page's own .highlight elements are untouched. */
|
||||
.highlight {
|
||||
padding: 1px 2px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
background-color: ${(props) => props.theme.accent!.mark};
|
||||
}
|
||||
|
||||
/* Snippet HTML can contain raw links. */
|
||||
a {
|
||||
color: ${(props) => props.theme.accent!.link};
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: ${(props) => props.theme.accent!.base};
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
|
||||
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;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
background-color: ${(props) => props.theme.primary.bg};
|
||||
border: 1px solid ${(props) => props.theme.hairline};
|
||||
border-radius: ${radii.sm};
|
||||
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 Loader = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 2rem auto;
|
||||
border: 2px solid ${(props) => props.theme.hairline};
|
||||
border-top-color: ${(props) => props.theme.accent!.base};
|
||||
border-radius: 50%;
|
||||
animation: ${spin} 0.8s linear infinite;
|
||||
`;
|
||||
|
||||
const NoResults = styled.div`
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: ${(props) => (props.theme.name === 'dark' ? '#E0E0E0' : '#505050')};
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
`;
|
||||
|
||||
const AskAIButton = styled.button`
|
||||
box-sizing: border-box;
|
||||
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};
|
||||
margin: 0 16px 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
background-color: ${(props) => props.theme.secondary.bg};
|
||||
border: 1px solid ${(props) => props.theme.hairline};
|
||||
border-radius: ${radii.md};
|
||||
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)'};
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&: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)'};
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.accent!.soft};
|
||||
border-color: ${(props) => props.theme.accent!.soft};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.accent!.base};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.theme.name === 'dark' ? '#FFFFFF24' : 'rgba(0, 0, 0, 0.14)'};
|
||||
padding: 4px 16px 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.hairline};
|
||||
`;
|
||||
|
||||
const TextField = styled.input`
|
||||
width: calc(100% - 32px);
|
||||
margin: 0 16px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
const TextField = styled.input<{ $hidden?: boolean }>`
|
||||
${(props) => (props.$hidden ? 'display: none;' : '')}
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 0;
|
||||
font-family: inherit;
|
||||
font-size: 18px;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
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;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.secondary.text};
|
||||
background-color: ${(props) => props.theme.secondary.bg};
|
||||
border: 1px solid ${(props) => props.theme.hairline};
|
||||
border-radius: ${radii.sm};
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
`;
|
||||
|
||||
const SearchNote = styled.div`
|
||||
margin: -4px 16px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.danger!.text};
|
||||
`;
|
||||
|
||||
export const SearchBar = ({
|
||||
@@ -362,6 +328,8 @@ export const SearchBar = ({
|
||||
placeholder = 'Search or Ask AI...',
|
||||
width = '256px',
|
||||
buttonText = 'Search here',
|
||||
allowedFileExtensions,
|
||||
showMicButton,
|
||||
}: SearchBarProps) => {
|
||||
const [input, setInput] = React.useState<string>('');
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
@@ -374,6 +342,21 @@ export const SearchBar = ({
|
||||
null,
|
||||
);
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null);
|
||||
const getSearchDraft = React.useCallback(
|
||||
() => inputRef.current?.value ?? '',
|
||||
[],
|
||||
);
|
||||
// Deferred until the input is unhidden; hidden fields can't take focus.
|
||||
const focusSearchInput = React.useCallback(() => {
|
||||
window.requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
const dictation = useDictation({
|
||||
enabled: Boolean(showMicButton),
|
||||
getDraft: getSearchDraft,
|
||||
onDraftChange: setInput,
|
||||
separator: ' ',
|
||||
onEnd: focusSearchInput,
|
||||
});
|
||||
const browserOS = getOS();
|
||||
const isTouch = 'ontouchstart' in window;
|
||||
|
||||
@@ -383,7 +366,6 @@ export const SearchBar = ({
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
loadGeistFont();
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
@@ -446,6 +428,11 @@ export const SearchBar = ({
|
||||
};
|
||||
}, [input]);
|
||||
|
||||
// Stop recording if the palette closes mid-dictation.
|
||||
React.useEffect(() => {
|
||||
if (!isResultVisible && dictation.isDictating) dictation.stop();
|
||||
}, [isResultVisible, dictation.isDictating, dictation.stop]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -464,9 +451,8 @@ export const SearchBar = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={{ ...themes[theme] }}>
|
||||
<ThemeProvider theme={themes[theme]}>
|
||||
<Main>
|
||||
<GlobalStyle />
|
||||
<Container ref={containerRef}>
|
||||
<SearchButton
|
||||
onClick={() => setIsResultVisible(true)}
|
||||
@@ -479,19 +465,47 @@ export const SearchBar = ({
|
||||
<SearchOverlay onClick={() => setIsResultVisible(false)} />
|
||||
<SearchResults>
|
||||
<SearchHeader>
|
||||
{dictation.isDictating && (
|
||||
<VoiceWaveform
|
||||
analyserRef={dictation.analyserRef}
|
||||
label={
|
||||
dictation.state === 'recording'
|
||||
? 'Listening…'
|
||||
: 'Finishing…'
|
||||
}
|
||||
minHeight="36px"
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
$hidden={dictation.isDictating}
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (dictation.error) dictation.clearError();
|
||||
setInput(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
{dictation.available && (
|
||||
<MicButton
|
||||
variant="icon"
|
||||
state={dictation.state}
|
||||
onClick={dictation.toggle}
|
||||
/>
|
||||
)}
|
||||
<EscapeInstruction onClick={() => setIsResultVisible(false)}>
|
||||
Esc
|
||||
</EscapeInstruction>
|
||||
</SearchHeader>
|
||||
<AskAIButton onClick={openWidget}>
|
||||
{dictation.error && (
|
||||
<SearchNote role="alert">{dictation.error}</SearchNote>
|
||||
)}
|
||||
<AskAIButton
|
||||
onClick={openWidget}
|
||||
disabled={dictation.isDictating}
|
||||
>
|
||||
<img src={DEFAULT_AVATAR} alt="" width={24} height={24} />
|
||||
<span>Ask the AI</span>
|
||||
</AskAIButton>
|
||||
@@ -600,6 +614,8 @@ export const SearchBar = ({
|
||||
isOpen={isWidgetOpen}
|
||||
handleClose={handleClose}
|
||||
size={'large'}
|
||||
allowedFileExtensions={allowedFileExtensions}
|
||||
showMicButton={showMicButton}
|
||||
/>
|
||||
</Main>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,3 +1,69 @@
|
||||
/** Colour tokens shared by the chat widget and the search bar. */
|
||||
export const themes = {
|
||||
dark: {
|
||||
bg: '#222327',
|
||||
text: '#fff',
|
||||
primary: {
|
||||
text: '#FAFAFA',
|
||||
bg: '#222327',
|
||||
},
|
||||
secondary: {
|
||||
text: '#A1A1AA',
|
||||
bg: '#33343A',
|
||||
},
|
||||
shimmer: {
|
||||
base: '#A1A1AA',
|
||||
highlight: '#FAFAFA',
|
||||
},
|
||||
accent: {
|
||||
base: '#8860DB',
|
||||
hover: '#9B7BE4',
|
||||
strong: '#6D42C5',
|
||||
contrast: '#FFFFFF',
|
||||
soft: 'rgba(136, 96, 219, 0.18)',
|
||||
mark: 'rgba(136, 96, 219, 0.18)',
|
||||
link: '#A78BFA',
|
||||
},
|
||||
hairline: 'rgba(255, 255, 255, 0.08)',
|
||||
danger: {
|
||||
text: '#F87171',
|
||||
soft: 'rgba(248, 113, 113, 0.10)',
|
||||
border: 'rgba(248, 113, 113, 0.32)',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
bg: '#fff',
|
||||
text: '#000',
|
||||
primary: {
|
||||
text: '#222327',
|
||||
bg: '#fff',
|
||||
},
|
||||
secondary: {
|
||||
text: '#71717A',
|
||||
bg: '#F4F4F5',
|
||||
},
|
||||
shimmer: {
|
||||
base: '#71717A',
|
||||
highlight: '#D4D4D8',
|
||||
},
|
||||
accent: {
|
||||
base: '#8860DB',
|
||||
hover: '#7A4FD0',
|
||||
strong: '#6D42C5',
|
||||
contrast: '#FFFFFF',
|
||||
soft: 'rgba(136, 96, 219, 0.12)',
|
||||
mark: 'rgba(136, 96, 219, 0.26)',
|
||||
link: '#6D42C5',
|
||||
},
|
||||
hairline: 'rgba(0, 0, 0, 0.08)',
|
||||
danger: {
|
||||
text: '#B91C1C',
|
||||
soft: 'rgba(185, 28, 28, 0.06)',
|
||||
border: 'rgba(185, 28, 28, 0.24)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Corner radii shared by the widget's chrome. */
|
||||
export const radii = {
|
||||
sm: '8px',
|
||||
|
||||
@@ -97,7 +97,7 @@ export const useAttachments = ({
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (outcome.state === 'completed') {
|
||||
// No id means nothing to send, whatever the task status said.
|
||||
// Without an attachment id there is nothing to send.
|
||||
if (outcome.attachmentId)
|
||||
patch(id, {
|
||||
status: 'completed',
|
||||
@@ -129,8 +129,7 @@ export const useAttachments = ({
|
||||
const id = generateId();
|
||||
const extension = fileExtension(file.name);
|
||||
|
||||
// A rejected file still gets a chip: mobile pickers ignore
|
||||
// `accept`, and a silent drop looks broken.
|
||||
// Mobile pickers ignore accept, so rejected files still get a chip.
|
||||
if (!acceptedExtensions.includes(extension)) {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { MicButtonState } from '../components/ComposerControls';
|
||||
import { useVoiceInput, voiceInputSupported } from './useVoiceInput';
|
||||
|
||||
interface UseDictationOptions {
|
||||
enabled: boolean;
|
||||
/** Read when dictation starts; the transcript is appended to it. */
|
||||
getDraft: () => string;
|
||||
onDraftChange: (value: string) => void;
|
||||
/** '\n' for a textarea, ' ' for a single-line input. */
|
||||
separator: string;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
/** Microphone state plus merging the live transcript into an input's draft. */
|
||||
export const useDictation = ({
|
||||
enabled,
|
||||
getDraft,
|
||||
onDraftChange,
|
||||
separator,
|
||||
onEnd,
|
||||
}: UseDictationOptions) => {
|
||||
const baseRef = React.useRef('');
|
||||
|
||||
const handleStart = React.useCallback(() => {
|
||||
baseRef.current = getDraft();
|
||||
}, [getDraft]);
|
||||
|
||||
const handleTranscript = React.useCallback(
|
||||
(text: string) => {
|
||||
// The base is captured once, so interim revisions only replace the
|
||||
// transcript.
|
||||
const base = baseRef.current;
|
||||
onDraftChange(
|
||||
base.trim() ? `${base.replace(/\s+$/, '')}${separator}${text}` : text,
|
||||
);
|
||||
},
|
||||
[onDraftChange, separator],
|
||||
);
|
||||
|
||||
const voice = useVoiceInput({
|
||||
onStart: handleStart,
|
||||
onTranscript: handleTranscript,
|
||||
onEnd,
|
||||
});
|
||||
|
||||
const state: MicButtonState =
|
||||
voice.recordingState === 'recording' ||
|
||||
voice.recordingState === 'transcribing'
|
||||
? voice.recordingState
|
||||
: 'idle';
|
||||
|
||||
return {
|
||||
// Firefox has no SpeechRecognition, nor does an insecure origin.
|
||||
available: enabled && voiceInputSupported(),
|
||||
state,
|
||||
isDictating: state !== 'idle',
|
||||
error: voice.error,
|
||||
clearError: voice.clearError,
|
||||
toggle: voice.toggle,
|
||||
stop: voice.stop,
|
||||
analyserRef: voice.analyserRef,
|
||||
};
|
||||
};
|
||||
@@ -39,13 +39,12 @@ const recognitionClass = (): SpeechRecognitionConstructor | undefined => {
|
||||
|
||||
export const voiceInputSupported = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
// Gated on a secure origin, where the failure looks identical to the API
|
||||
// being absent.
|
||||
// The API is only exposed on secure origins.
|
||||
if (!window.isSecureContext) return false;
|
||||
return recognitionClass() !== undefined;
|
||||
};
|
||||
|
||||
/** Message worth showing, or null for outcomes that are not errors. */
|
||||
/** User-facing message, or null for outcomes that are not errors. */
|
||||
const errorMessage = (code: string): string | null => {
|
||||
switch (code) {
|
||||
// The user pressed stop.
|
||||
@@ -74,7 +73,7 @@ interface UseVoiceInputOptions {
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
/** The composer's microphone; one button toggles it. */
|
||||
/** Browser speech recognition with interim results. */
|
||||
export const useVoiceInput = ({
|
||||
onStart,
|
||||
onTranscript,
|
||||
@@ -84,12 +83,12 @@ export const useVoiceInput = ({
|
||||
React.useState<RecordingState>('idle');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const recognitionRef = React.useRef<SpeechRecognitionLike | null>(null);
|
||||
// The Web Speech API exposes no audio, so the waveform needs a second
|
||||
// capture of its own. Kept in a ref so the draw loop re-renders nothing.
|
||||
// The Web Speech API exposes no audio, so the waveform uses a separate
|
||||
// capture. Refs, so drawing does not re-render.
|
||||
const analyserRef = React.useRef<AnalyserNode | null>(null);
|
||||
const audioContextRef = React.useRef<AudioContext | null>(null);
|
||||
const levelStreamRef = React.useRef<MediaStream | null>(null);
|
||||
// Finals accumulate; the interim tail is rebuilt each event, never kept.
|
||||
// Final results accumulate; interim text is rebuilt on each event.
|
||||
const finalTranscriptRef = React.useRef('');
|
||||
const isMountedRef = React.useRef(true);
|
||||
|
||||
@@ -101,23 +100,21 @@ export const useVoiceInput = ({
|
||||
audioContextRef.current = null;
|
||||
}, []);
|
||||
|
||||
/** Best-effort: a browser that refuses this still transcribes fine. */
|
||||
/** Best effort: recognition still works if this capture is refused. */
|
||||
const startLevelMeter = React.useCallback(async () => {
|
||||
const audioWindow = window as LegacyAudioWindow;
|
||||
const AudioContextClass =
|
||||
audioWindow.AudioContext ?? audioWindow.webkitAudioContext;
|
||||
if (!AudioContextClass || !navigator.mediaDevices?.getUserMedia) return;
|
||||
|
||||
// Never `await context.resume()`: without user activation that promise
|
||||
// never settles at all, stranding everything below it, so no analyser is
|
||||
// built. Constructed before the first await so it stays under the click's
|
||||
// activation, which is insurance for browsers that do not treat a live
|
||||
// capture as activation the way Chrome does.
|
||||
// Never `await context.resume()`: without user activation the promise
|
||||
// never settles and the analyser below is never built. Constructed before
|
||||
// the first await so it is created within the click's activation.
|
||||
const context = new AudioContextClass();
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// Recording may have ended while permission was granted.
|
||||
// Recording may have ended while permission was pending.
|
||||
if (!isMountedRef.current || !recognitionRef.current) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
void context.close().catch(() => undefined);
|
||||
@@ -127,8 +124,7 @@ export const useVoiceInput = ({
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = LEVEL_FFT_SIZE;
|
||||
analyser.smoothingTimeConstant = LEVEL_SMOOTHING;
|
||||
// Not connected to the destination: that would play the microphone
|
||||
// back through the page's speakers.
|
||||
// Left unconnected to the destination, which would play the mic back.
|
||||
context.createMediaStreamSource(stream).connect(analyser);
|
||||
|
||||
levelStreamRef.current = stream;
|
||||
@@ -143,8 +139,7 @@ export const useVoiceInput = ({
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
// `abort`, not `stop`: nothing left to deliver, and it frees the
|
||||
// microphone at once.
|
||||
// abort() releases the microphone immediately.
|
||||
recognitionRef.current?.abort();
|
||||
recognitionRef.current = null;
|
||||
stopLevelMeter();
|
||||
@@ -175,8 +170,8 @@ export const useVoiceInput = ({
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let interim = '';
|
||||
// From `resultIndex`, not 0: the API returns the whole list every
|
||||
// time, and earlier results are already banked.
|
||||
// Start at resultIndex: the list also contains results already
|
||||
// accumulated.
|
||||
for (
|
||||
let index = event.resultIndex;
|
||||
index < event.results.length;
|
||||
@@ -199,8 +194,7 @@ export const useVoiceInput = ({
|
||||
|
||||
recognition.onend = () => {
|
||||
recognitionRef.current = null;
|
||||
// Not on stop(), so the bars stay live while the last utterance
|
||||
// finalises.
|
||||
// Here, so the waveform stays live until the final result arrives.
|
||||
stopLevelMeter();
|
||||
if (!isMountedRef.current) return;
|
||||
// An error already set its own state; do not overwrite it.
|
||||
@@ -224,22 +218,26 @@ export const useVoiceInput = ({
|
||||
void startLevelMeter();
|
||||
}, [onEnd, onStart, onTranscript, startLevelMeter, stopLevelMeter]);
|
||||
|
||||
const stop = React.useCallback(() => {
|
||||
if (recordingState !== 'recording') return;
|
||||
// stop() lets the last utterance deliver its final result.
|
||||
setRecordingState('transcribing');
|
||||
recognitionRef.current?.stop();
|
||||
}, [recordingState]);
|
||||
|
||||
const toggle = React.useCallback(() => {
|
||||
if (recordingState === 'transcribing') return;
|
||||
if (recordingState === 'recording') {
|
||||
// `stop`, not `abort`: the last utterance still has a final result
|
||||
// to deliver.
|
||||
setRecordingState('transcribing');
|
||||
recognitionRef.current?.stop();
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
start();
|
||||
}, [recordingState, start]);
|
||||
}, [recordingState, start, stop]);
|
||||
|
||||
const clearError = React.useCallback(() => {
|
||||
setError(null);
|
||||
setRecordingState((previous) => (previous === 'error' ? 'idle' : previous));
|
||||
}, []);
|
||||
|
||||
return { recordingState, error, toggle, clearError, analyserRef };
|
||||
return { recordingState, error, toggle, stop, clearError, analyserRef };
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* `/api/store_attachment` returns a Celery task id; the parsed attachment
|
||||
* only exists once that task lands, hence the polling below. The id `/stream`
|
||||
* expects is the task result's `attachment_id`, not the one the upload
|
||||
* response carries. Auth is the agent key as `api_key` in the multipart body.
|
||||
* `/api/store_attachment` returns a Celery task id; the attachment row only
|
||||
* exists once that task finishes, hence the polling below. Sending its id to
|
||||
* `/stream` any earlier finds no row, and the attachment is silently dropped.
|
||||
* Auth is the agent key as `api_key` in the multipart body.
|
||||
*/
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
@@ -105,11 +105,11 @@ export function uploadAttachment({
|
||||
}
|
||||
|
||||
/**
|
||||
* A 503 or transport failure reads as `pending`: the worker fleet is
|
||||
* unreachable, which says nothing about this file.
|
||||
* A 503 or transport failure is treated as `pending`: those mean the worker
|
||||
* fleet is unreachable, and the task may still complete.
|
||||
*
|
||||
* A failed task's `result` is the raw exception, which has no business on a
|
||||
* third-party page, so no reason is returned and the caller supplies one.
|
||||
* A failed task's `result` is the raw exception, so no reason is returned;
|
||||
* the caller supplies one.
|
||||
*/
|
||||
export async function fetchTaskOutcome(
|
||||
taskId: string,
|
||||
|
||||
@@ -12,8 +12,6 @@ declare module 'styled-components' {
|
||||
text: string;
|
||||
bg: string;
|
||||
};
|
||||
/** Present only in SearchBar theme */
|
||||
name?: string;
|
||||
/** Gradient stops for the swept status text. */
|
||||
shimmer?: {
|
||||
base: string;
|
||||
@@ -25,6 +23,8 @@ declare module 'styled-components' {
|
||||
strong: string;
|
||||
contrast: string;
|
||||
soft: string;
|
||||
/** Background behind a search keyword match. */
|
||||
mark: string;
|
||||
link: string;
|
||||
};
|
||||
hairline?: string;
|
||||
@@ -51,7 +51,10 @@ export type Status = 'idle' | 'loading' | 'failed';
|
||||
export type FEEDBACK = 'LIKE' | 'DISLIKE';
|
||||
|
||||
export type AttachmentStatus =
|
||||
'uploading' | 'processing' | 'completed' | 'failed';
|
||||
| 'uploading'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface Attachment {
|
||||
/** Client-side key for the chip; never sent to the server. */
|
||||
@@ -62,7 +65,7 @@ export interface Attachment {
|
||||
progress: number;
|
||||
/** Server-side id; what `/stream` expects in its `attachments` array. */
|
||||
attachmentId?: string;
|
||||
/** Why it failed, when there is something worth showing. */
|
||||
/** User-facing failure reason, if any. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -120,17 +123,15 @@ export interface WidgetProps {
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* File extensions the composer accepts, e.g. `['.pdf', '.md', '.png']`.
|
||||
* Attachments stay off until this is set, since every uploaded file is
|
||||
* parsed and billed against the key owner's token budget. A leading dot is
|
||||
* optional and matching is case-insensitive.
|
||||
* Attachments are disabled while unset. A leading dot is optional and
|
||||
* matching is case-insensitive.
|
||||
*/
|
||||
allowedFileExtensions?: string[];
|
||||
/**
|
||||
* Show the microphone that dictates into the input via the browser's Web
|
||||
* Speech API. Off by default: outside Chromium builds with on-device
|
||||
* recognition the browser forwards audio to its vendor's speech service.
|
||||
* The button hides itself where the API is missing or the origin is
|
||||
* insecure.
|
||||
* Show a microphone that dictates into the input via the browser's Web
|
||||
* Speech API. Hidden where the API is unavailable or the origin is
|
||||
* insecure. Outside Chromium builds with on-device recognition, audio is
|
||||
* sent to the browser vendor's speech service.
|
||||
*/
|
||||
showMicButton?: boolean;
|
||||
}
|
||||
@@ -141,7 +142,14 @@ export interface WidgetCoreProps extends WidgetProps {
|
||||
prefilledQuery?: string;
|
||||
}
|
||||
|
||||
export interface SearchBarProps {
|
||||
/**
|
||||
* Both props are forwarded to the chat opened from "Ask the AI";
|
||||
* `showMicButton` also adds a microphone to the search field.
|
||||
*/
|
||||
export interface SearchBarProps extends Pick<
|
||||
WidgetProps,
|
||||
'allowedFileExtensions' | 'showMicButton'
|
||||
> {
|
||||
apiHost?: string;
|
||||
apiKey?: string;
|
||||
theme?: THEME;
|
||||
|
||||
Reference in new issue
Block a user