diff --git a/src/browser/components/ChatInput/VoiceInputButton.tsx b/src/browser/components/ChatInput/VoiceInputButton.tsx index 228d330b5d..288cfeaa6f 100644 --- a/src/browser/components/ChatInput/VoiceInputButton.tsx +++ b/src/browser/components/ChatInput/VoiceInputButton.tsx @@ -14,6 +14,7 @@ interface VoiceInputButtonProps { state: VoiceInputState; isApiKeySet: boolean; shouldShowUI: boolean; + requiresSecureContext: boolean; onToggle: () => void; disabled?: boolean; } @@ -27,9 +28,17 @@ const STATE_CONFIG: Record = (props) => { if (!props.shouldShowUI) return null; - const needsApiKey = !props.isApiKeySet; - const { label, colorClass } = needsApiKey - ? { label: "Voice input (requires OpenAI API key)", colorClass: "text-muted/50" } + const needsHttps = props.requiresSecureContext; + const needsApiKey = !needsHttps && !props.isApiKeySet; + const isDisabledReason = needsHttps || needsApiKey; + + const { label, colorClass } = isDisabledReason + ? { + label: needsHttps + ? "Voice input (requires HTTPS)" + : "Voice input (requires OpenAI API key)", + colorClass: "text-muted/50", + } : STATE_CONFIG[props.state]; const Icon = props.state === "transcribing" ? Loader2 : Mic; @@ -40,7 +49,7 @@ export const VoiceInputButton: React.FC = (props) => { - {needsApiKey ? ( + {needsHttps ? ( + <> + Voice input requires a secure connection. +
+ Use HTTPS or access via localhost. + + ) : needsApiKey ? ( <> Voice input requires OpenAI API key.
diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 0d6e215390..0c0b936876 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1066,6 +1066,7 @@ export const ChatInput: React.FC = (props) => { state={voiceInput.state} isApiKeySet={voiceInput.isApiKeySet} shouldShowUI={voiceInput.shouldShowUI} + requiresSecureContext={voiceInput.requiresSecureContext} onToggle={voiceInput.toggle} disabled={disabled || isSending} /> diff --git a/src/browser/hooks/useVoiceInput.ts b/src/browser/hooks/useVoiceInput.ts index 46d273d3dd..3ce6821be9 100644 --- a/src/browser/hooks/useVoiceInput.ts +++ b/src/browser/hooks/useVoiceInput.ts @@ -24,6 +24,8 @@ export interface UseVoiceInputResult { isApiKeySet: boolean; /** False on touch devices (they have native keyboard dictation) */ shouldShowUI: boolean; + /** True when running over HTTP (not localhost) - microphone requires secure context */ + requiresSecureContext: boolean; start: () => void; stop: (options?: { send?: boolean }) => void; cancel: () => void; @@ -49,6 +51,8 @@ function hasTouchDictation(): boolean { const HAS_TOUCH_DICTATION = hasTouchDictation(); const HAS_MEDIA_RECORDER = typeof window !== "undefined" && typeof MediaRecorder !== "undefined"; +const HAS_GET_USER_MEDIA = + typeof window !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function"; // ============================================================================= // Hook @@ -131,6 +135,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul // Guard: only start from idle state with valid configuration const canStart = HAS_MEDIA_RECORDER && + HAS_GET_USER_MEDIA && !HAS_TOUCH_DICTATION && state === "idle" && callbacksRef.current.openAIKeySet; @@ -237,9 +242,10 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul return { state, - isSupported: HAS_MEDIA_RECORDER, + isSupported: HAS_MEDIA_RECORDER && HAS_GET_USER_MEDIA, isApiKeySet: callbacksRef.current.openAIKeySet, shouldShowUI: HAS_MEDIA_RECORDER && !HAS_TOUCH_DICTATION, + requiresSecureContext: HAS_MEDIA_RECORDER && !HAS_GET_USER_MEDIA, start: () => void start(), stop, cancel,