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 = ({
) : (
)}
- {MIC_LABELS[state]}
+ {variant === 'pill' && MIC_LABELS[state]}
);
@@ -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 (
-
+
{label}
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;