Skip to content
Merged
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
19 changes: 17 additions & 2 deletions frontend/features/chat/components/sections/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from "react";
import dynamic from "next/dynamic";
import { Box, CornerDownRight, Eye, EyeOff, Film, Image, ImageOff, ImagePlus, LoaderCircle, PencilLine, Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner";

import { AudioLines } from "@/components/animate-ui/icons/audio-lines";
Expand All @@ -24,7 +24,10 @@ import {
formatClipboardMarkdownPaste,
resolveClipboardMarkdownPaste,
} from "@/features/chat/utils/markdown-paste";
import { useChatSpeechInput } from "@/features/chat/hooks/use-chat-speech-input";
import {
useChatSpeechInput,
type SpeechInputErrorCode,
} from "@/features/chat/hooks/use-chat-speech-input";
import { useMarkdownPreviewSync } from "@/features/chat/hooks/use-markdown-preview-sync";
import {
useChatMentionMenu,
Expand Down Expand Up @@ -279,17 +282,27 @@ function ChatInputComponent({
const tChat = useTranslations("chat");
const tComposer = useTranslations("chat.composer");
const tFileStatus = useTranslations("files.status");
const locale = useLocale();
const [isBlocksHovered, setIsBlocksHovered] = React.useState(false);
const [isVoiceHovered, setIsVoiceHovered] = React.useState(false);
const [toolsMenuHovered, setToolsMenuHovered] = React.useState(false);
const [toolsMenuOpen, setToolsMenuOpen] = React.useState(false);
const [editingQueuedMessageID, setEditingQueuedMessageID] = React.useState<string | null>(null);
const [editingQueuedMessageContent, setEditingQueuedMessageContent] = React.useState("");
const handleSpeechInputError = React.useCallback((error: SpeechInputErrorCode) => {
toast.error(tComposer("voiceErrorTitle"), {
id: "chat-speech-input-error",
description: tComposer(`voiceErrors.${error}`),
});
}, [tComposer]);
const speechInput = useChatSpeechInput({
draft,
language: locale,
listeningPlaceholder: tComposer("voiceListeningPlaceholder"),
onDraftChange,
onError: handleSpeechInputError,
placeholder: tComposer("inputPlaceholder"),
startingPlaceholder: tComposer("voiceStartingPlaceholder"),
});
const [hoveredTool, setHoveredTool] = React.useState<"upload" | "screenshot" | null>(null);
const [ragWarnDismissed, setRagWarnDismissed] = React.useState(false);
Expand Down Expand Up @@ -1056,6 +1069,8 @@ function ChatInputComponent({
strokeWidth={1.4}
animate="default-loop"
/>
) : speechInput.status === "starting" ? (
<LoaderCircle className="size-5 animate-spin" strokeWidth={1.6} />
) : speechInput.active ? (
<AudioLines
size={20}
Expand Down
166 changes: 128 additions & 38 deletions frontend/features/chat/hooks/use-chat-speech-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ type BrowserSpeechRecognitionResultList = {
};

type BrowserSpeechRecognitionEvent = Event & {
resultIndex: number;
results: BrowserSpeechRecognitionResultList;
};

Expand All @@ -43,13 +42,37 @@ type BrowserWindowWithSpeechRecognition = Window & {
webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor;
};

export type SpeechInputStatus = "idle" | "listening";
export type SpeechInputStatus = "idle" | "starting" | "listening";

export type SpeechInputErrorCode =
| "audioUnavailable"
| "interrupted"
| "languageUnsupported"
| "network"
| "noSpeech"
| "permissionDenied"
| "serviceUnavailable"
| "startFailed"
| "unavailable";

const MAX_EMPTY_RESTARTS = 2;
const RESTART_DELAY_MS = 250;
const SPEECH_RECOGNITION_ERROR_CODES: Readonly<Record<string, SpeechInputErrorCode>> = {
"audio-capture": "audioUnavailable",
"language-not-supported": "languageUnsupported",
network: "network",
"not-allowed": "permissionDenied",
"service-not-allowed": "serviceUnavailable",
};

type UseChatSpeechInputParams = {
draft: string;
language: string;
listeningPlaceholder: string;
onDraftChange: (value: string) => void;
onError: (error: SpeechInputErrorCode) => void;
placeholder: string;
startingPlaceholder: string;
};

type UseChatSpeechInputState = {
Expand All @@ -62,48 +85,63 @@ type UseChatSpeechInputState = {

export function useChatSpeechInput({
draft,
language,
listeningPlaceholder,
onDraftChange,
onError,
placeholder,
startingPlaceholder,
}: UseChatSpeechInputParams): UseChatSpeechInputState {
const [supported, setSupported] = React.useState(false);
const [status, setStatus] = React.useState<SpeechInputStatus>("idle");
const recognitionRef = React.useRef<BrowserSpeechRecognition | null>(null);
const draftRef = React.useRef(draft);
const baseDraftRef = React.useRef("");
const renderedDraftRef = React.useRef("");
const cancelledRef = React.useRef(false);
const emptyRestartCountRef = React.useRef(0);
const sessionHadResultRef = React.useRef(false);
const recoverableErrorRef = React.useRef<"aborted" | "no-speech" | null>(null);
const restartTimerRef = React.useRef<number | null>(null);

const active = status !== "idle";
const resolvedPlaceholder = active ? listeningPlaceholder : placeholder;
const resolvedPlaceholder = status === "starting"
? startingPlaceholder
: status === "listening"
? listeningPlaceholder
: placeholder;

React.useEffect(() => {
draftRef.current = draft;
}, [draft]);

React.useEffect(() => {
const browserWindow = window as BrowserWindowWithSpeechRecognition;
setSupported(Boolean(browserWindow.SpeechRecognition ?? browserWindow.webkitSpeechRecognition));
const RecognitionConstructor = browserWindow.SpeechRecognition ?? browserWindow.webkitSpeechRecognition;
setSupported(window.isSecureContext && Boolean(RecognitionConstructor));

return () => {
if (restartTimerRef.current !== null) {
window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
cancelledRef.current = true;
recognitionRef.current?.stop();
const recognition = recognitionRef.current;
recognitionRef.current = null;
recognition?.stop();
};
}, []);

const commitTranscript = React.useCallback(
(finalTranscript: string, interimTranscript: string) => {
const fragments = [
const nextDraft = [
baseDraftRef.current,
finalTranscript.trim(),
interimTranscript.trim(),
].filter(Boolean);
onDraftChange(fragments.join(" "));
].filter(Boolean).join(" ");
renderedDraftRef.current = nextDraft;
draftRef.current = nextDraft;
onDraftChange(nextDraft);
},
[onDraftChange],
);
Expand All @@ -114,12 +152,15 @@ export function useChatSpeechInput({
window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
recognitionRef.current?.stop();
const recognition = recognitionRef.current;
recognitionRef.current = null;
recognition?.stop();
setStatus("idle");
}, []);

const toggle = React.useCallback(() => {
if (!supported) {
onError("unavailable");
return;
}
if (active) {
Expand All @@ -131,22 +172,57 @@ export function useChatSpeechInput({
const RecognitionConstructor = browserWindow.SpeechRecognition ?? browserWindow.webkitSpeechRecognition;
if (!RecognitionConstructor) {
setSupported(false);
onError("unavailable");
return;
}

cancelledRef.current = false;
emptyRestartCountRef.current = 0;
baseDraftRef.current = draftRef.current.trimEnd();
renderedDraftRef.current = baseDraftRef.current;

const failStart = () => {
cancelledRef.current = true;
recognitionRef.current = null;
setStatus("idle");
onError("startFailed");
};

const startRecognition = () => {
const recognition = new RecognitionConstructor();
recognition.continuous = true;
let recognition: BrowserSpeechRecognition;
try {
recognition = new RecognitionConstructor();
} catch {
failStart();
return;
}

sessionHadResultRef.current = false;
recoverableErrorRef.current = null;
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = navigator.language || "zh-CN";
recognition.lang = language;

const finishWithError = (error: SpeechInputErrorCode) => {
if (recognitionRef.current !== recognition) {
return;
}
cancelledRef.current = true;
recognitionRef.current = null;
setStatus("idle");
onError(error);
};

recognition.onstart = () => {
setStatus("listening");
if (!cancelledRef.current && recognitionRef.current === recognition) {
setStatus("listening");
}
};
recognition.onresult = (event) => {
setStatus("listening");
if (cancelledRef.current || recognitionRef.current !== recognition) {
return;
}

const finalTranscripts: string[] = [];
const interimTranscripts: string[] = [];
for (let resultIndex = 0; resultIndex < event.results.length; resultIndex += 1) {
Expand All @@ -164,54 +240,68 @@ export function useChatSpeechInput({
interimTranscripts.push(transcript);
}
}
if (finalTranscripts.length === 0 && interimTranscripts.length === 0) {
return;
}

sessionHadResultRef.current = true;
emptyRestartCountRef.current = 0;
recoverableErrorRef.current = null;
setStatus("listening");
commitTranscript(finalTranscripts.join(" "), interimTranscripts.join(" "));
};
recognition.onerror = (event) => {
if (cancelledRef.current) {
setStatus("idle");
if (cancelledRef.current || recognitionRef.current !== recognition) {
return;
}
if (event.error === "no-speech" || event.error === "aborted") {
setStatus("listening");
recoverableErrorRef.current = event.error;
setStatus("starting");
return;
}
cancelledRef.current = true;
recognitionRef.current = null;
setStatus("idle");

finishWithError(SPEECH_RECOGNITION_ERROR_CODES[event.error] ?? "unavailable");
};
recognition.onend = () => {
if (!cancelledRef.current) {
if (recognitionRef.current !== recognition) {
return;
}
setStatus("listening");
restartTimerRef.current = window.setTimeout(() => {
restartTimerRef.current = null;
if (cancelledRef.current || recognitionRef.current !== recognition) {
return;
}
startRecognition();
}, 180);
if (recognitionRef.current !== recognition) {
return;
}
if (recognitionRef.current === recognition) {
if (cancelledRef.current) {
recognitionRef.current = null;
setStatus("idle");
return;
}
setStatus("idle");

baseDraftRef.current = renderedDraftRef.current.trimEnd();
if (!sessionHadResultRef.current) {
emptyRestartCountRef.current += 1;
if (emptyRestartCountRef.current > MAX_EMPTY_RESTARTS) {
finishWithError(recoverableErrorRef.current === "aborted" ? "interrupted" : "noSpeech");
return;
}
}

setStatus("starting");
restartTimerRef.current = window.setTimeout(() => {
restartTimerRef.current = null;
if (cancelledRef.current || recognitionRef.current !== recognition) {
return;
}
startRecognition();
}, RESTART_DELAY_MS);
};

recognitionRef.current = recognition;
setStatus("starting");
try {
recognition.start();
setStatus("listening");
} catch {
recognitionRef.current = null;
setStatus("idle");
failStart();
}
};

startRecognition();
}, [active, commitTranscript, stop, supported]);
}, [active, commitTranscript, language, onError, stop, supported]);

return {
supported,
Expand Down
13 changes: 13 additions & 0 deletions frontend/i18n/messages/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@
"screenshot": "Screenshot",
"openTools": "Open tools",
"inputPlaceholder": "Message, or use / for context, @ for resources",
"voiceStartingPlaceholder": "Starting voice input…",
"voiceListeningPlaceholder": "Receiving voice input…",
"modelOptions": "Parameters",
"mcpTools": "MCP tools",
Expand Down Expand Up @@ -431,6 +432,18 @@
"voiceInput": "Voice input",
"cancelVoiceInput": "Cancel voice input",
"voiceUnsupported": "Voice input is not supported by this browser",
"voiceErrorTitle": "Voice input stopped",
"voiceErrors": {
"audioUnavailable": "No available microphone was found. Check the microphone permission and device.",
"interrupted": "Speech recognition was repeatedly interrupted. Please try again.",
"languageUnsupported": "The browser speech service does not support the current language.",
"network": "The browser speech service could not be reached. Check the network and try again.",
"noSpeech": "No speech was detected. Please try again and speak closer to the microphone.",
"permissionDenied": "Microphone permission was denied. Allow microphone access and try again.",
"serviceUnavailable": "The browser speech service is unavailable on this device.",
"startFailed": "Speech recognition could not be started. Please try again.",
"unavailable": "Voice input is unavailable in the current browser environment."
},
"booleanOn": "On",
"booleanOff": "Off",
"tool": "Tool {id}",
Expand Down
Loading
Loading