diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 23757353..48208b40 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -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(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 | 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({
{inlineError} diff --git a/webui/src/hooks/useVoiceRecorder.ts b/webui/src/hooks/useVoiceRecorder.ts index 26803f3d..4b0da850 100644 --- a/webui/src/hooks/useVoiceRecorder.ts +++ b/webui/src/hooks/useVoiceRecorder.ts @@ -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 | 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"; +} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index dec2ea21..03279618 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -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": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 89defe57..d6631ec5 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -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": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 236b95de..b0e50aea 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index aedb7866..e40f154c 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 231f5ff4..31fd0705 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -963,11 +963,12 @@ }, "voiceErrors": { "unsupported": "このブラウザーは音声入力に対応していません。", - "permission": "マイクの許可が必要です。", + "permission": "アドレスバーでマイクを許可してから再試行してください。", "notConfigured": "先に文字起こしプロバイダーを設定してください。", "tooLong": "録音が長すぎます。", "tooShort": "もう少し長く録音してください。", "noInput": "マイク入力が検出されませんでした。", + "noDevice": "マイクが見つかりません。接続して再試行してください。", "failed": "音声を文字起こしできませんでした。" }, "slash": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 8e21ffe3..2fbeef81 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -963,11 +963,12 @@ }, "voiceErrors": { "unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.", - "permission": "마이크 권한이 필요합니다.", + "permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.", "notConfigured": "먼저 음성 변환 제공업체를 설정하세요.", "tooLong": "녹음 시간이 너무 깁니다.", "tooShort": "음성을 녹음하려면 조금 더 길게 눌러 주세요.", "noInput": "마이크 입력이 감지되지 않았습니다.", + "noDevice": "마이크를 찾을 수 없습니다. 연결 후 다시 시도하세요.", "failed": "오디오를 변환하지 못했습니다." }, "slash": { diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index f2899d01..420e6d33 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -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": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 152c5b32..451c8aa6 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 6331fcbc..8325e87f 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -976,11 +976,12 @@ }, "voiceErrors": { "unsupported": "当前浏览器不支持语音输入。", - "permission": "需要麦克风权限。", + "permission": "请在地址栏允许麦克风后重试。", "notConfigured": "请先配置转写提供商。", "tooLong": "录音时间太长。", "tooShort": "请稍微多录一会儿。", "noInput": "没有检测到麦克风输入。", + "noDevice": "没有找到可用的麦克风。请连接麦克风后重试。", "failed": "语音转写失败。" }, "slash": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 7a563c58..ec1e5c83 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -963,11 +963,12 @@ }, "voiceErrors": { "unsupported": "目前瀏覽器不支援語音輸入。", - "permission": "需要麥克風權限。", + "permission": "請在網址列允許麥克風後重試。", "notConfigured": "請先設定轉寫供應商。", "tooLong": "錄音時間太長。", "tooShort": "請延長錄音時間。", "noInput": "沒有偵測到麥克風輸入。", + "noDevice": "找不到可用的麥克風,請連接後重試。", "failed": "語音轉寫失敗。" }, "slash": { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 1fa83010..e47c1a25 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -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( + "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( + , + ); + + 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();