From 289ea7da8dfcbd4a37b68b3576f586af8bf505e7 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Tue, 15 Sep 2026 04:18:04 +0530 Subject: [PATCH] feat(widget): align search bar with widget theme, add voice search --- docs/content/Extensions/search-widget.mdx | 4 +- extensions/react-widget/README.md | 2 + extensions/react-widget/src/App.tsx | 2 +- .../src/components/ComposerControls.tsx | 51 +- .../src/components/DocsGPTWidget.tsx | 201 ++------ .../react-widget/src/components/SearchBar.tsx | 446 +++++++++--------- .../react-widget/src/components/tokens.ts | 66 +++ .../react-widget/src/hooks/useAttachments.ts | 5 +- .../react-widget/src/hooks/useDictation.ts | 65 +++ .../react-widget/src/hooks/useVoiceInput.ts | 54 +-- .../src/requests/attachmentsApi.ts | 16 +- extensions/react-widget/src/types/index.ts | 34 +- 12 files changed, 506 insertions(+), 440 deletions(-) create mode 100644 extensions/react-widget/src/hooks/useDictation.ts diff --git a/docs/content/Extensions/search-widget.mdx b/docs/content/Extensions/search-widget.mdx index 93a15350..879fba57 100644 --- a/docs/content/Extensions/search-widget.mdx +++ b/docs/content/Extensions/search-widget.mdx @@ -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). | --- diff --git a/extensions/react-widget/README.md b/extensions/react-widget/README.md index d93ed110..858235d9 100644 --- a/extensions/react-widget/README.md +++ b/extensions/react-widget/README.md @@ -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`! diff --git a/extensions/react-widget/src/App.tsx b/extensions/react-widget/src/App.tsx index b30249e3..0c1f60ba 100644 --- a/extensions/react-widget/src/App.tsx +++ b/extensions/react-widget/src/App.tsx @@ -4,7 +4,7 @@ import { SearchBar } from './components/SearchBar'; export const App = () => { return (
- + ` 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. void; + variant?: 'pill' | 'icon'; }) => ( @@ -422,7 +428,7 @@ export const MicButton = ({ ) : ( ); @@ -472,22 +478,21 @@ export const SentAttachments = ({ ); -// 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; label: string; + /** Min height of the input it replaces, to avoid a layout shift. */ + minHeight?: string; }) => { const canvasRef = React.useRef(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 ( - + diff --git a/extensions/react-widget/src/components/DocsGPTWidget.tsx b/extensions/react-widget/src/components/DocsGPTWidget.tsx index c6a0669f..58aee967 100644 --- a/extensions/react-widget/src/components/DocsGPTWidget.tsx +++ b/extensions/react-widget/src/components/DocsGPTWidget.tsx @@ -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) => ( ); -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(null); const promptRef = React.useRef(null); const attachmentInputRef = React.useRef(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 = ({ {isDictating && ( )} - {/* Hidden, not unmounted: the ref stays live for reading - the draft and refocusing afterwards. */} + {/* Kept mounted so promptRef stays valid while dictating. */} )} - {canUseVoice && ( + {dictation.available && ( )} diff --git a/extensions/react-widget/src/components/SearchBar.tsx b/extensions/react-widget/src/components/SearchBar.tsx index f3e6b6ea..f8f75b27 100644 --- a/extensions/react-widget/src/components/SearchBar.tsx +++ b/extensions/react-widget/src/components/SearchBar.tsx @@ -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(''); const [loading, setLoading] = React.useState(false); @@ -374,6 +342,21 @@ export const SearchBar = ({ null, ); const abortControllerRef = React.useRef(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) => { if (event.key === 'Enter') { event.preventDefault(); @@ -464,9 +451,8 @@ export const SearchBar = ({ }; return ( - +
- setIsResultVisible(true)} @@ -479,19 +465,47 @@ export const SearchBar = ({ setIsResultVisible(false)} /> + {dictation.isDictating && ( + + )} setInput(e.target.value)} + onChange={(e) => { + if (dictation.error) dictation.clearError(); + setInput(e.target.value); + }} onKeyDown={(e) => handleKeyDown(e)} placeholder={placeholder} autoFocus /> + {dictation.available && ( + + )} setIsResultVisible(false)}> Esc - + {dictation.error && ( + {dictation.error} + )} + Ask the AI @@ -600,6 +614,8 @@ export const SearchBar = ({ isOpen={isWidgetOpen} handleClose={handleClose} size={'large'} + allowedFileExtensions={allowedFileExtensions} + showMicButton={showMicButton} />
diff --git a/extensions/react-widget/src/components/tokens.ts b/extensions/react-widget/src/components/tokens.ts index 097cf678..b9ae788a 100644 --- a/extensions/react-widget/src/components/tokens.ts +++ b/extensions/react-widget/src/components/tokens.ts @@ -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', diff --git a/extensions/react-widget/src/hooks/useAttachments.ts b/extensions/react-widget/src/hooks/useAttachments.ts index 24f59da9..c81e9f88 100644 --- a/extensions/react-widget/src/hooks/useAttachments.ts +++ b/extensions/react-widget/src/hooks/useAttachments.ts @@ -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, diff --git a/extensions/react-widget/src/hooks/useDictation.ts b/extensions/react-widget/src/hooks/useDictation.ts new file mode 100644 index 00000000..34a06711 --- /dev/null +++ b/extensions/react-widget/src/hooks/useDictation.ts @@ -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, + }; +}; diff --git a/extensions/react-widget/src/hooks/useVoiceInput.ts b/extensions/react-widget/src/hooks/useVoiceInput.ts index 516f48b3..0185c8c3 100644 --- a/extensions/react-widget/src/hooks/useVoiceInput.ts +++ b/extensions/react-widget/src/hooks/useVoiceInput.ts @@ -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('idle'); const [error, setError] = React.useState(null); const recognitionRef = React.useRef(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(null); const audioContextRef = React.useRef(null); const levelStreamRef = React.useRef(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 }; }; diff --git a/extensions/react-widget/src/requests/attachmentsApi.ts b/extensions/react-widget/src/requests/attachmentsApi.ts index 13117d11..829fda48 100644 --- a/extensions/react-widget/src/requests/attachmentsApi.ts +++ b/extensions/react-widget/src/requests/attachmentsApi.ts @@ -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, diff --git a/extensions/react-widget/src/types/index.ts b/extensions/react-widget/src/types/index.ts index 5a07ab94..9a81af79 100644 --- a/extensions/react-widget/src/types/index.ts +++ b/extensions/react-widget/src/types/index.ts @@ -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;