diff --git a/web/src/App.tsx b/web/src/App.tsx index 3199c40a..5f0bc2e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' import { SyncingBanner } from '@/components/SyncingBanner' +import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' import { ToastProvider, useToast } from '@/lib/toast-context' @@ -317,6 +318,7 @@ function AppInner() { +
diff --git a/web/src/components/VoiceErrorBanner.tsx b/web/src/components/VoiceErrorBanner.tsx new file mode 100644 index 00000000..d0a98c0a --- /dev/null +++ b/web/src/components/VoiceErrorBanner.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react' +import { useVoiceOptional } from '@/lib/voice-context' + +export function VoiceErrorBanner() { + const voice = useVoiceOptional() + + const shouldShow = voice && voice.status === 'error' && voice.errorMessage + + useEffect(() => { + if (!shouldShow || !voice) return + + const timer = setTimeout(() => { + voice.setStatus('disconnected') + }, 3000) + + return () => clearTimeout(timer) + }, [shouldShow, voice]) + + if (!shouldShow) { + return null + } + + return ( +
+ {voice.errorMessage} +
+ ) +} diff --git a/web/src/components/VoiceStatusBar.tsx b/web/src/components/VoiceStatusBar.tsx deleted file mode 100644 index 9e6b7d75..00000000 --- a/web/src/components/VoiceStatusBar.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useTranslation } from '@/lib/use-translation' -import type { ConversationStatus } from '@/realtime/types' - -interface VoiceStatusBarProps { - status: ConversationStatus - onStop: () => void - micMuted?: boolean - onMicToggle?: () => void -} - -function MicrophoneIcon(props: { muted?: boolean }) { - if (props.muted) { - return ( - - - - - - - - - ) - } - - return ( - - - - - - ) -} - -function StatusDot(props: { color: 'yellow' | 'green' | 'gray' | 'red'; pulsing?: boolean }) { - const colorClasses = { - yellow: 'bg-yellow-500', - green: 'bg-emerald-500', - gray: 'bg-gray-400', - red: 'bg-red-500' - } - - const pulsingColorClasses = { - yellow: 'bg-yellow-400', - green: 'bg-emerald-400', - gray: 'bg-gray-300', - red: 'bg-red-400' - } - - if (props.pulsing) { - return ( - - - - - ) - } - - return -} - -export function VoiceStatusBar({ status, onStop, micMuted, onMicToggle }: VoiceStatusBarProps) { - const { t } = useTranslation() - - if (status === 'disconnected') { - return null - } - - // Determine status dot appearance - let dotColor: 'yellow' | 'green' | 'gray' | 'red' = 'green' - let dotPulsing = false - - if (status === 'connecting') { - dotColor = 'yellow' - dotPulsing = true - } else if (status === 'error') { - dotColor = 'red' - } else if (micMuted) { - dotColor = 'gray' - } - - // Determine status text - const statusText = status === 'connecting' - ? t('voice.connecting') - : status === 'error' - ? t('voice.error') - : micMuted - ? t('voice.muted') - : t('voice.active') - - return ( -
- {/* Left section: status dot + mic icon + status text */} - - - {/* Right section: mute button + end button */} -
- {status === 'connected' && onMicToggle && ( - - )} - - -
-
- ) -} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 3a5698ee..fd570e8d 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -201,6 +201,13 @@ export default { 'voice.mute': 'Mute', 'voice.unmute': 'Unmute', 'voice.end': 'End', + 'voice.error.micPermission': 'Microphone permission denied', + 'voice.error.network': 'Network error', + 'voice.error.notInitialized': 'Voice session not initialized', + 'voice.error.startFailed': 'Failed to start voice session', + 'voice.error.notAllowed': 'Voice not allowed', + 'voice.error.connection': 'Connection error', + 'voice.dismiss': 'Dismiss', // Banners 'offline.title': 'Offline', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index cf8019f3..4842e5d3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -203,6 +203,13 @@ export default { 'voice.mute': '静音', 'voice.unmute': '取消静音', 'voice.end': '结束', + 'voice.error.micPermission': '麦克风权限被拒绝', + 'voice.error.network': '网络错误', + 'voice.error.notInitialized': '语音会话未初始化', + 'voice.error.startFailed': '启动语音会话失败', + 'voice.error.notAllowed': '语音功能不可用', + 'voice.error.connection': '连接错误', + 'voice.dismiss': '关闭', // Banners 'offline.title': '离线', diff --git a/web/src/lib/voice-context.tsx b/web/src/lib/voice-context.tsx index 9754c008..473ce195 100644 --- a/web/src/lib/voice-context.tsx +++ b/web/src/lib/voice-context.tsx @@ -1,12 +1,13 @@ import { createContext, useCallback, useContext, useState, type ReactNode } from 'react' -import type { ConversationStatus } from '@/realtime/types' +import type { ConversationStatus, StatusCallback } from '@/realtime/types' import { startRealtimeSession, stopRealtimeSession, voiceHooks } from '@/realtime' interface VoiceContextValue { status: ConversationStatus + errorMessage: string | null micMuted: boolean currentSessionId: string | null - setStatus: (status: ConversationStatus) => void + setStatus: (status: ConversationStatus, errorMessage?: string) => void setMicMuted: (muted: boolean) => void toggleMic: () => void startVoice: (sessionId: string) => Promise @@ -16,10 +17,20 @@ interface VoiceContextValue { const VoiceContext = createContext(null) export function VoiceProvider({ children }: { children: ReactNode }) { - const [status, setStatus] = useState('disconnected') + const [status, setStatusInternal] = useState('disconnected') + const [errorMessage, setErrorMessage] = useState(null) const [micMuted, setMicMuted] = useState(false) const [currentSessionId, setCurrentSessionId] = useState(null) + const setStatus: StatusCallback = useCallback((newStatus, error) => { + setStatusInternal(newStatus) + if (newStatus === 'error') { + setErrorMessage(error ?? null) + } else if (newStatus === 'connected') { + setErrorMessage(null) + } + }, []) + const toggleMic = useCallback(() => { setMicMuted((prev) => !prev) }, []) @@ -34,13 +45,15 @@ export function VoiceProvider({ children }: { children: ReactNode }) { voiceHooks.onVoiceStopped() await stopRealtimeSession() setCurrentSessionId(null) - setStatus('disconnected') + setStatusInternal('disconnected') + setErrorMessage(null) }, []) return ( | null = null // Store reference for status updates -let statusCallback: ((status: ConversationStatus) => void) | null = null +let statusCallback: StatusCallback | null = null // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { @@ -28,7 +28,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { if (!conversationInstance) { const error = new Error('Realtime voice session not initialized') console.warn('[Voice] Realtime voice session not initialized') - statusCallback?.('error') + statusCallback?.('error', 'Voice session not initialized') throw error } @@ -39,7 +39,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { await navigator.mediaDevices.getUserMedia({ audio: true }) } catch (error) { console.error('[Voice] Failed to get microphone permission:', error) - statusCallback?.('error') + statusCallback?.('error', 'Microphone permission denied') throw error } @@ -49,13 +49,13 @@ class RealtimeVoiceSessionImpl implements VoiceSession { tokenResponse = await fetchVoiceToken(this.api) } catch (error) { console.error('[Voice] Failed to fetch voice token:', error) - statusCallback?.('error') + statusCallback?.('error', 'Network error') throw error } if (!tokenResponse.allowed || !tokenResponse.token) { const error = new Error(tokenResponse.error ?? 'Voice not allowed or no token') console.error('[Voice] Voice not allowed or no token:', tokenResponse.error) - statusCallback?.('error') + statusCallback?.('error', tokenResponse.error ?? 'Voice not allowed') throw error } @@ -75,7 +75,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { } } catch (error) { console.error('[Voice] Failed to start realtime session:', error) - statusCallback?.('error') + statusCallback?.('error', 'Failed to start voice session') throw error } } @@ -115,7 +115,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { export interface RealtimeVoiceSessionProps { api: ApiClient micMuted?: boolean - onStatusChange?: (status: ConversationStatus) => void + onStatusChange?: StatusCallback getSession?: (sessionId: string) => Session | null sendMessage?: (sessionId: string, message: string) => void approvePermission?: (sessionId: string, requestId: string) => Promise @@ -174,7 +174,8 @@ export function RealtimeVoiceSession({ const handleError = useCallback((error: unknown) => { if (DEBUG) console.error('[Voice] Realtime error:', error) - onStatusChange?.('error') + const errorMessage = error instanceof Error ? error.message : 'Connection error' + onStatusChange?.('error', errorMessage) }, [onStatusChange]) const handleMessage = useCallback((data: unknown) => { diff --git a/web/src/realtime/types.ts b/web/src/realtime/types.ts index 814f6313..3daefa38 100644 --- a/web/src/realtime/types.ts +++ b/web/src/realtime/types.ts @@ -12,3 +12,5 @@ export interface VoiceSession { export type ConversationStatus = 'disconnected' | 'connecting' | 'connected' | 'error' export type ConversationMode = 'speaking' | 'listening' + +export type StatusCallback = (status: ConversationStatus, errorMessage?: string) => void