fix(webui): detect Chrome voice recording support (#5027)
This commit is contained in:
@@ -97,6 +97,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
const VOICE_SHORTCUT_ARIA = "Control+Shift+D";
|
||||
const VOICE_ERROR_VISIBLE_MS = 3_500;
|
||||
const VOICE_ERROR_FADE_MS = 500;
|
||||
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
@@ -819,6 +821,7 @@ export function ThreadComposer({
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
|
||||
@@ -839,6 +842,7 @@ export function ThreadComposer({
|
||||
const skipNextQueuedFlushRef = useRef(false);
|
||||
const skipQueuedPromptPersistRef = useRef(false);
|
||||
const voiceShortcutDownRef = useRef(false);
|
||||
const voiceErrorFadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isHero = variant === "hero";
|
||||
const voiceShortcutLabel = useMemo(getVoiceShortcutLabel, []);
|
||||
const queuedPromptStorageKey = useMemo(
|
||||
@@ -1287,10 +1291,28 @@ export function ThreadComposer({
|
||||
resizeTextarea();
|
||||
}, [resizeTextarea]);
|
||||
|
||||
const clearInlineError = useCallback(() => setInlineError(null), []);
|
||||
const clearVoiceErrorTimers = useCallback(() => {
|
||||
if (voiceErrorFadeTimerRef.current !== null) clearTimeout(voiceErrorFadeTimerRef.current);
|
||||
voiceErrorFadeTimerRef.current = null;
|
||||
}, []);
|
||||
const clearInlineError = useCallback(() => {
|
||||
clearVoiceErrorTimers();
|
||||
setVoiceErrorFading(false);
|
||||
setInlineError(null);
|
||||
}, [clearVoiceErrorTimers]);
|
||||
const setVoiceError = useCallback((key: VoiceRecorderErrorKey) => {
|
||||
clearVoiceErrorTimers();
|
||||
setVoiceErrorFading(false);
|
||||
setInlineError(t(`thread.composer.voiceErrors.${key}`));
|
||||
}, [t]);
|
||||
voiceErrorFadeTimerRef.current = setTimeout(() => {
|
||||
setVoiceErrorFading(true);
|
||||
voiceErrorFadeTimerRef.current = setTimeout(() => {
|
||||
setInlineError(null);
|
||||
setVoiceErrorFading(false);
|
||||
voiceErrorFadeTimerRef.current = null;
|
||||
}, VOICE_ERROR_FADE_MS);
|
||||
}, VOICE_ERROR_VISIBLE_MS);
|
||||
}, [clearVoiceErrorTimers, t]);
|
||||
const voiceRecorder = useVoiceRecorder({
|
||||
disabled,
|
||||
onClearError: clearInlineError,
|
||||
@@ -1300,6 +1322,8 @@ export function ThreadComposer({
|
||||
wantsWav: transcriptionProvider === "xiaomi_mimo",
|
||||
});
|
||||
|
||||
useEffect(() => () => clearVoiceErrorTimers(), [clearVoiceErrorTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onTranscribeAudio) return;
|
||||
|
||||
@@ -1907,8 +1931,9 @@ export function ThreadComposer({
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"mx-3 mb-1 rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
||||
"text-[11.5px] font-medium text-destructive",
|
||||
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
||||
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] duration-500 ease-out",
|
||||
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
{inlineError}
|
||||
|
||||
@@ -29,6 +29,7 @@ const VOICE_MIME_CANDIDATES = [
|
||||
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
||||
export type VoiceRecorderErrorKey =
|
||||
| "failed"
|
||||
| "noDevice"
|
||||
| "noInput"
|
||||
| "notConfigured"
|
||||
| "permission"
|
||||
@@ -181,14 +182,17 @@ export function useVoiceRecorder({
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
||||
onClearError();
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
const MediaRecorderCtor = mediaRecorderConstructor();
|
||||
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
||||
onError("unsupported");
|
||||
return;
|
||||
}
|
||||
startPendingRef.current = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream, mediaRecorderOptions());
|
||||
const stream = await mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorderCtor(stream, mediaRecorderOptions(MediaRecorderCtor));
|
||||
chunksRef.current = [];
|
||||
streamRef.current = stream;
|
||||
mediaRecorderRef.current = recorder;
|
||||
@@ -251,10 +255,10 @@ export function useVoiceRecorder({
|
||||
noInputHintVisibleRef.current = true;
|
||||
onError("noInput");
|
||||
}, VOICE_NO_INPUT_HINT_MS);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
cleanupRecording();
|
||||
setState("idle");
|
||||
onError("permission");
|
||||
onError(recordingErrorKey(error));
|
||||
}
|
||||
}, [
|
||||
cleanupRecording,
|
||||
@@ -370,12 +374,21 @@ function clearTimer(ref: { current: ReturnType<typeof setTimeout> | null }) {
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRecorderOptions(): MediaRecorderOptions | undefined {
|
||||
if (typeof MediaRecorder === "undefined") return undefined;
|
||||
const mimeType = VOICE_MIME_CANDIDATES.find((type) => MediaRecorder.isTypeSupported(type));
|
||||
function mediaRecorderOptions(MediaRecorderCtor: MediaRecorderConstructor): MediaRecorderOptions | undefined {
|
||||
const mimeType = VOICE_MIME_CANDIDATES.find((type) => MediaRecorderCtor.isTypeSupported?.(type));
|
||||
return mimeType ? { mimeType } : undefined;
|
||||
}
|
||||
|
||||
type MediaRecorderConstructor = typeof MediaRecorder;
|
||||
|
||||
function mediaRecorderConstructor(): MediaRecorderConstructor | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const browserWindow = window as Window & {
|
||||
MediaRecorder?: MediaRecorderConstructor;
|
||||
};
|
||||
return browserWindow.MediaRecorder;
|
||||
}
|
||||
|
||||
function formatVoiceElapsed(ms: number): string {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -510,3 +523,9 @@ function transcriptionErrorKey(error: unknown): VoiceRecorderErrorKey {
|
||||
if (detail === "duration") return "tooLong";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function recordingErrorKey(error: unknown): VoiceRecorderErrorKey {
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
if (name === "NotFoundError") return "noDevice";
|
||||
return "permission";
|
||||
}
|
||||
|
||||
@@ -977,11 +977,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "Voice input is not supported in this browser.",
|
||||
"permission": "Microphone permission is required.",
|
||||
"permission": "Allow microphone access in the address bar, then retry.",
|
||||
"notConfigured": "Configure a transcription provider first.",
|
||||
"tooLong": "Recording is too long.",
|
||||
"tooShort": "Hold a little longer to record voice.",
|
||||
"noInput": "No microphone input detected.",
|
||||
"noDevice": "No microphone was found. Connect a microphone and try again.",
|
||||
"failed": "Could not transcribe audio."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -964,11 +964,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "Este navegador no admite entrada de voz.",
|
||||
"permission": "Se requiere permiso de micrófono.",
|
||||
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
||||
"notConfigured": "Configura primero un proveedor de transcripción.",
|
||||
"tooLong": "La grabación es demasiado larga.",
|
||||
"tooShort": "Mantén pulsado un poco más para grabar voz.",
|
||||
"noInput": "No se detectó entrada del micrófono.",
|
||||
"noDevice": "No se encontró ningún micrófono. Conéctalo y vuelve a intentarlo.",
|
||||
"failed": "No se pudo transcribir el audio."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -963,11 +963,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "La saisie vocale n'est pas prise en charge par ce navigateur.",
|
||||
"permission": "L'autorisation du microphone est requise.",
|
||||
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
|
||||
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
||||
"tooLong": "L'enregistrement est trop long.",
|
||||
"tooShort": "Maintenez un peu plus longtemps pour enregistrer la voix.",
|
||||
"noInput": "Aucune entrée microphone détectée.",
|
||||
"noDevice": "Aucun microphone trouvé. Connectez-en un, puis réessayez.",
|
||||
"failed": "Impossible de transcrire l'audio."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -963,11 +963,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "Input suara tidak didukung di browser ini.",
|
||||
"permission": "Izin mikrofon diperlukan.",
|
||||
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
||||
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
||||
"tooLong": "Rekaman terlalu panjang.",
|
||||
"tooShort": "Tahan sedikit lebih lama untuk merekam suara.",
|
||||
"noInput": "Tidak ada input mikrofon yang terdeteksi.",
|
||||
"noDevice": "Mikrofon tidak ditemukan. Hubungkan mikrofon lalu coba lagi.",
|
||||
"failed": "Tidak dapat mentranskripsi audio."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -963,11 +963,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "このブラウザーは音声入力に対応していません。",
|
||||
"permission": "マイクの許可が必要です。",
|
||||
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
||||
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
||||
"tooLong": "録音が長すぎます。",
|
||||
"tooShort": "もう少し長く録音してください。",
|
||||
"noInput": "マイク入力が検出されませんでした。",
|
||||
"noDevice": "マイクが見つかりません。接続して再試行してください。",
|
||||
"failed": "音声を文字起こしできませんでした。"
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -963,11 +963,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
||||
"permission": "마이크 권한이 필요합니다.",
|
||||
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
||||
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
||||
"tooLong": "녹음 시간이 너무 깁니다.",
|
||||
"tooShort": "음성을 녹음하려면 조금 더 길게 눌러 주세요.",
|
||||
"noInput": "마이크 입력이 감지되지 않았습니다.",
|
||||
"noDevice": "마이크를 찾을 수 없습니다. 연결 후 다시 시도하세요.",
|
||||
"failed": "오디오를 변환하지 못했습니다."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -977,11 +977,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "A entrada de voz não é compatível com este navegador.",
|
||||
"permission": "É necessária permissão do microfone.",
|
||||
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
||||
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
||||
"tooLong": "A gravação é longa demais.",
|
||||
"tooShort": "Segure um pouco mais para gravar voz.",
|
||||
"noInput": "Não foi detectada entrada do microfone.",
|
||||
"noDevice": "Nenhum microfone encontrado. Conecte um e tente novamente.",
|
||||
"failed": "Não foi possível transcrever o áudio."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -963,11 +963,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "Trình duyệt này không hỗ trợ nhập bằng giọng nói.",
|
||||
"permission": "Cần quyền truy cập micrô.",
|
||||
"permission": "Cho phép micrô trong thanh địa chỉ rồi thử lại.",
|
||||
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
|
||||
"tooLong": "Bản ghi âm quá dài.",
|
||||
"tooShort": "Giữ lâu hơn một chút để ghi âm giọng nói.",
|
||||
"noInput": "Không phát hiện đầu vào micrô.",
|
||||
"noDevice": "Không tìm thấy micrô. Hãy kết nối rồi thử lại.",
|
||||
"failed": "Không thể chép lời âm thanh."
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -976,11 +976,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "当前浏览器不支持语音输入。",
|
||||
"permission": "需要麦克风权限。",
|
||||
"permission": "请在地址栏允许麦克风后重试。",
|
||||
"notConfigured": "请先配置转写提供商。",
|
||||
"tooLong": "录音时间太长。",
|
||||
"tooShort": "请稍微多录一会儿。",
|
||||
"noInput": "没有检测到麦克风输入。",
|
||||
"noDevice": "没有找到可用的麦克风。请连接麦克风后重试。",
|
||||
"failed": "语音转写失败。"
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -963,11 +963,12 @@
|
||||
},
|
||||
"voiceErrors": {
|
||||
"unsupported": "目前瀏覽器不支援語音輸入。",
|
||||
"permission": "需要麥克風權限。",
|
||||
"permission": "請在網址列允許麥克風後重試。",
|
||||
"notConfigured": "請先設定轉寫供應商。",
|
||||
"tooLong": "錄音時間太長。",
|
||||
"tooShort": "請延長錄音時間。",
|
||||
"noInput": "沒有偵測到麥克風輸入。",
|
||||
"noDevice": "找不到可用的麥克風,請連接後重試。",
|
||||
"failed": "語音轉寫失敗。"
|
||||
},
|
||||
"slash": {
|
||||
|
||||
@@ -439,6 +439,49 @@ describe("ThreadComposer", () => {
|
||||
await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("one recording"));
|
||||
});
|
||||
|
||||
it("distinguishes a missing microphone from a blocked permission", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
getUserMedia.mockRejectedValue(Object.assign(new Error("no microphone"), {
|
||||
name: "NotFoundError",
|
||||
}));
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No microphone was found. Connect a microphone and try again.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a previous voice error when retrying microphone access", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
getUserMedia.mockRejectedValueOnce(new Error("permission denied"));
|
||||
const onTranscribeAudio = vi.fn(async () => "voice retry");
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const voiceButton = screen.getByRole("button", { name: "Voice input" });
|
||||
fireEvent.click(voiceButton);
|
||||
await waitFor(() => expect(screen.getByText("Allow microphone access in the address bar, then retry.")).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(voiceButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Allow microphone access in the address bar, then retry.")).not.toBeInTheDocument());
|
||||
expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop recording" }));
|
||||
});
|
||||
|
||||
it("supports press-and-hold voice recording", async () => {
|
||||
mockVoiceRecorder();
|
||||
const onSend = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user