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
25 changes: 20 additions & 5 deletions src/browser/components/ChatInput/VoiceInputButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface VoiceInputButtonProps {
state: VoiceInputState;
isApiKeySet: boolean;
shouldShowUI: boolean;
requiresSecureContext: boolean;
onToggle: () => void;
disabled?: boolean;
}
Expand All @@ -27,9 +28,17 @@ const STATE_CONFIG: Record<VoiceInputState, { label: string; colorClass: string
export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (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;
Expand All @@ -40,7 +49,7 @@ export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
<button
type="button"
onClick={props.onToggle}
disabled={(props.disabled ?? false) || isTranscribing || needsApiKey}
disabled={(props.disabled ?? false) || isTranscribing || isDisabledReason}
aria-label={label}
aria-pressed={props.state === "recording"}
className={cn(
Expand All @@ -52,7 +61,13 @@ export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
<Icon className={cn("h-4 w-4", isTranscribing && "animate-spin")} strokeWidth={1.5} />
</button>
<Tooltip className="tooltip" align="right">
{needsApiKey ? (
{needsHttps ? (
<>
Voice input requires a secure connection.
<br />
Use HTTPS or access via localhost.
</>
) : needsApiKey ? (
<>
Voice input requires OpenAI API key.
<br />
Expand Down
1 change: 1 addition & 0 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
state={voiceInput.state}
isApiKeySet={voiceInput.isApiKeySet}
shouldShowUI={voiceInput.shouldShowUI}
requiresSecureContext={voiceInput.requiresSecureContext}
onToggle={voiceInput.toggle}
disabled={disabled || isSending}
/>
Expand Down
8 changes: 7 additions & 1 deletion src/browser/hooks/useVoiceInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down