Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ chainlit run demo.py -w

<img src="/images/quick-start.png" alt="Quick Start"></img>

### Browser dictation

To dictate text into the message composer without Python audio callbacks, set the following in `.chainlit/config.toml`:

```toml
[features.audio]
enabled = true
mode = "browser"
```

Click the microphone, allow microphone access, and speak. You can stop recording, edit the recognized text, and send it as a normal message. The existing audio streaming mode remains the default (`mode = "realtime"`).

Browser dictation requires Web Speech API support, such as `SpeechRecognition` or `webkitSpeechRecognition`. Some browsers send audio to their vendor's recognition service, so this mode does not guarantee offline processing. The microphone button is disabled when the browser does not support recognition.

## 📚 More Examples - Cookbook

You can find various examples of Chainlit apps [here](https://github.com/Chainlit/cookbook) that leverage tools and services such as OpenAI, Anthropiс, LangChain, LlamaIndex, ChromaDB, Pinecone and more.
Expand Down
4 changes: 4 additions & 0 deletions backend/chainlit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@
[features.audio]
# Enable audio features
enabled = false
# "realtime" streams audio to Python callbacks; "browser" dictates into the composer.
# Browser dictation requires Web Speech API support and may use a browser vendor's cloud service.
mode = "realtime"
# Sample rate of the audio
sample_rate = 24000

Expand Down Expand Up @@ -315,6 +318,7 @@ class SpontaneousFileUploadFeature(BaseModel):
class AudioFeature(BaseModel):
sample_rate: int = 24000
enabled: bool = False
mode: Literal["realtime", "browser"] = "realtime"


class SlackFeature(BaseModel):
Expand Down
5 changes: 4 additions & 1 deletion backend/chainlit/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@
"speech": {
"start": "Start recording",
"stop": "Stop recording",
"connecting": "Connecting"
"connecting": "Connecting",
"dictate": "Dictate a message",
"unsupported": "Speech recognition is not supported by this browser",
"failed": "Speech recognition failed: {{error}}"
},
"fileUpload": {
"dragDrop": "Drag and drop files here",
Expand Down
15 changes: 15 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from chainlit import config as chainlit_config
from chainlit.config import (
AudioFeature,
ChainlitConfig,
ChainlitConfigOverrides,
FeaturesSettings,
Expand All @@ -19,6 +20,20 @@
from chainlit.version import __version__


def test_audio_mode_defaults_to_realtime():
assert AudioFeature(enabled=True).mode == "realtime"


def test_browser_audio_mode_is_serialized():
features = FeaturesSettings(audio={"enabled": True, "mode": "browser"})
assert features.model_dump()["audio"]["mode"] == "browser"


def test_unknown_audio_mode_is_rejected():
with pytest.raises(ValidationError, match="mode"):
AudioFeature(mode="unknown")


@pytest.fixture
def translation_dir(tmp_path: Path) -> Path:
"""Minimal translation directory with a controlled set of locale files."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Mic, Square } from 'lucide-react';

import { useTranslation } from '@/components/i18n/Translator';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';

export default function DictationButton({
disabled,
supported,
listening,
onClick
}: {
disabled: boolean;
supported: boolean;
listening: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
const label = t(
!supported
? 'chat.speech.unsupported'
: listening
? 'chat.speech.stop'
: 'chat.speech.dictate'
);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
disabled={disabled || !supported}
variant="ghost"
size="icon"
aria-label={label}
aria-pressed={listening}
onClick={onClick}
>
{listening ? (
<Square className="!size-5" />
) : (
<Mic className="!size-5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
31 changes: 28 additions & 3 deletions frontend/src/components/chat/MessageComposer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useState
} from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { toast } from 'sonner';
import { v4 as uuidv4 } from 'uuid';

import {
Expand All @@ -32,6 +33,7 @@ import { useTranslation } from 'components/i18n/Translator';

import { useQuery } from '@/hooks/query';
import { useIsMobile } from '@/hooks/use-mobile';
import { useBrowserSpeechRecognition } from '@/hooks/useBrowserSpeechRecognition';

import { chatSettingsOpenState } from '@/state/project';
import {
Expand All @@ -43,6 +45,7 @@ import {
import { Attachments } from './Attachments';
import CommandButtons from './CommandButtons';
import CommandButton from './CommandPopoverButton';
import DictationButton from './DictationButton';
import FavoriteButton from './FavoriteButton';
import Input, { InputMethods } from './Input';
import McpButton from './Mcp';
Expand Down Expand Up @@ -80,7 +83,7 @@ export default function MessageComposer({
}
}, [commands]);
const [attachments, setAttachments] = useRecoilState(attachmentsState);
const { t } = useTranslation();
const { t, i18n } = useTranslation();

const { user } = useAuth();
const { sendMessage, replyMessage } = useChatInteract();
Expand All @@ -89,6 +92,17 @@ export default function MessageComposer({
const disabled = _disabled || !!attachments.find((a) => !a.uploaded);

const { config } = useConfig();
const browserDictation =
!!config?.features.audio.enabled &&
config.features.audio.mode === 'browser';
const dictation = useBrowserSpeechRecognition({
enabled: browserDictation && !disabled,
language: i18n.language,
onTranscript: (text) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The onTranscript handler appends dictated text to the React value state: setValueExtern([value, text].join(' ')). If two final recognition results arrive before React re-renders, both use the same stale value, so the second overwrites the first instead of appending to it and dictated text is lost. Read the live input text rather than the value state (e.g., from inputRef.current or an accumulating ref) so consecutive results always build on the latest content.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/components/chat/MessageComposer/index.tsx, line 101:

<comment>The onTranscript handler appends dictated text to the React `value` state: `setValueExtern([value, text].join(' '))`. If two final recognition results arrive before React re-renders, both use the same stale `value`, so the second overwrites the first instead of appending to it and dictated text is lost. Read the live input text rather than the `value` state (e.g., from `inputRef.current` or an accumulating ref) so consecutive results always build on the latest content.</comment>

<file context>
@@ -89,6 +92,17 @@ export default function MessageComposer({
+  const dictation = useBrowserSpeechRecognition({
+    enabled: browserDictation && !disabled,
+    language: i18n.language,
+    onTranscript: (text) => {
+      inputRef.current?.setValueExtern([value, text].filter(Boolean).join(' '));
+    },
</file context>

inputRef.current?.setValueExtern([value, text].filter(Boolean).join(' '));
},
onError: (error) => toast.error(t('chat.speech.failed', { error }))
});
const showSettingsInComposer =
config?.ui?.chat_settings_location !== 'sidebar' &&
chatSettingsInputs.length > 0;
Expand Down Expand Up @@ -231,6 +245,7 @@ export default function MessageComposer({
onSubmit(value, attachments, selectedCommand?.id);
}

dictation.cancel();
setAttachments([]);
setValue(''); // Clear the value state
inputRef.current?.reset();
Expand All @@ -242,7 +257,8 @@ export default function MessageComposer({
selectedCommand,
setAttachments,
onSubmit,
onReply
onReply,
dictation.cancel
]);

useEffect(() => {
Expand Down Expand Up @@ -282,7 +298,16 @@ export default function MessageComposer({
/>
<div className="flex items-center justify-between">
<div className="flex items-center -ml-1.5">
<VoiceButton disabled={disabled} />
{browserDictation ? (
<DictationButton
disabled={disabled}
supported={dictation.supported}
listening={dictation.listening}
onClick={dictation.toggle}
/>
) : (
<VoiceButton disabled={disabled} />
)}
<UploadButton
disabled={disabled}
fileSpec={fileSpec}
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/hooks/useBrowserSpeechRecognition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from 'react';

export interface SpeechResultEvent {
resultIndex: number;
results: ArrayLike<{
isFinal: boolean;
[index: number]: { transcript: string };
}>;
}

export interface BrowserSpeechRecognition {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: SpeechResultEvent) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
}

type SpeechWindow = Window & {
SpeechRecognition?: new () => BrowserSpeechRecognition;
webkitSpeechRecognition?: new () => BrowserSpeechRecognition;
};

export function useBrowserSpeechRecognition({
enabled,
language,
onTranscript,
onError
}: {
enabled: boolean;
language: string;
onTranscript: (text: string) => void;
onError: (error: string) => void;
}) {
const [listening, setListening] = useState(false);
const recognitionRef = useRef<BrowserSpeechRecognition | null>(null);
const callbacks = useRef({ onTranscript, onError });
callbacks.current = { onTranscript, onError };
const speechWindow = window as SpeechWindow;
const Recognition =
speechWindow.SpeechRecognition || speechWindow.webkitSpeechRecognition;

const dispose = useCallback(() => {
const recognition = recognitionRef.current;
recognitionRef.current = null;
if (recognition) {
recognition.onresult = null;
recognition.onerror = null;
recognition.onend = null;
recognition.abort();
}
}, []);

const cancel = useCallback(() => {
dispose();
setListening(false);
}, [dispose]);

useEffect(() => {
if (!enabled) cancel();
return dispose;
}, [enabled, cancel, dispose]);

const toggle = useCallback(() => {
if (!enabled || !Recognition) return;
if (recognitionRef.current) {
recognitionRef.current.stop();
return;
}

const recognition = new Recognition();
recognitionRef.current = recognition;
recognition.lang = language;
recognition.continuous = true;
recognition.interimResults = false;
const delivered = new Set<number>();
recognition.onresult = (event) => {
const transcripts: string[] = [];
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal && !delivered.has(i)) {
delivered.add(i);
transcripts.push(result[0].transcript.trim());
}
}
const transcript = transcripts.filter(Boolean).join(' ');
if (transcript) callbacks.current.onTranscript(transcript);
};
recognition.onend = () => {
recognitionRef.current = null;
recognition.onresult = null;
recognition.onerror = null;
recognition.onend = null;
setListening(false);
};
recognition.onerror = (event) => {
cancel();
if (event.error !== 'aborted' && event.error !== 'no-speech') {
callbacks.current.onError(event.error);
}
};
try {
setListening(true);
recognition.start();
} catch (error) {
cancel();
callbacks.current.onError(
error instanceof Error ? error.message : 'unknown'
);
}
}, [enabled, Recognition, language, cancel]);

return { supported: !!Recognition, listening, toggle, cancel };
}
Loading