Merge pull request #2782 from ManishMadan2882/main

feat(widget): align search bar with widget theme, add voice search
This commit is contained in:
Alex authored and GitHub committed 2026-09-16 21:28:06 +01:00
commit fbcf320458
12 files changed
+506 -440

No files matched your search

+3 -1
View File
@@ -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). |
---
+2
View File
@@ -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`!
+1 -1
View File
@@ -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,
+21 -13
View File
@@ -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;