Add error hint

This commit is contained in:
weishu
2026-01-19 19:03:01 +08:00
parent e9db67e18b
commit a928e82db2
8 changed files with 73 additions and 159 deletions
+2
View File
@@ -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() {
<AppContextProvider value={{ api, token, baseUrl }}>
<VoiceProvider>
<SyncingBanner isSyncing={isSyncing} />
<VoiceErrorBanner />
<OfflineBanner />
<div className="h-full flex flex-col">
<Outlet />
+28
View File
@@ -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 (
<div className="fixed top-0 left-0 right-0 bg-red-500 text-white text-center py-2 text-sm font-medium z-50 flex items-center justify-center border-b border-red-600">
{voice.errorMessage}
</div>
)
}
-146
View File
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
<path d="M5 10v2a7 7 0 0 0 12 5" />
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
)
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
)
}
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 (
<span className="relative flex h-2 w-2">
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full ${pulsingColorClasses[props.color]} opacity-75`}></span>
<span className={`relative inline-flex h-2 w-2 rounded-full ${colorClasses[props.color]}`}></span>
</span>
)
}
return <span className={`inline-flex h-2 w-2 rounded-full ${colorClasses[props.color]}`}></span>
}
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 (
<div className="flex h-8 items-center justify-between rounded-lg bg-[var(--app-secondary-bg)] px-3">
{/* Left section: status dot + mic icon + status text */}
<button
type="button"
onClick={onStop}
className="flex flex-1 items-center gap-1.5 text-[var(--app-fg)] transition-opacity hover:opacity-70"
>
<StatusDot color={dotColor} pulsing={dotPulsing} />
<MicrophoneIcon muted={micMuted} />
<span className="text-sm font-medium">{statusText}</span>
</button>
{/* Right section: mute button + end button */}
<div className="flex items-center gap-3">
{status === 'connected' && onMicToggle && (
<button
type="button"
onClick={onMicToggle}
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-[var(--app-fg)] transition-opacity hover:opacity-70"
>
<MicrophoneIcon muted={micMuted} />
<span>{micMuted ? t('voice.unmute') : t('voice.mute')}</span>
</button>
)}
<button
type="button"
onClick={onStop}
className="rounded px-2 py-1 text-xs font-medium text-[var(--app-fg)] transition-opacity hover:opacity-70"
>
{t('voice.end')}
</button>
</div>
</div>
)
}
+7
View File
@@ -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',
+7
View File
@@ -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': '离线',
+17 -4
View File
@@ -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<void>
@@ -16,10 +17,20 @@ interface VoiceContextValue {
const VoiceContext = createContext<VoiceContextValue | null>(null)
export function VoiceProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<ConversationStatus>('disconnected')
const [status, setStatusInternal] = useState<ConversationStatus>('disconnected')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [micMuted, setMicMuted] = useState(false)
const [currentSessionId, setCurrentSessionId] = useState<string | null>(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 (
<VoiceContext.Provider
value={{
status,
errorMessage,
micMuted,
currentSessionId,
setStatus,
+10 -9
View File
@@ -3,7 +3,7 @@ import { useConversation } from '@elevenlabs/react'
import { registerVoiceSession } from './RealtimeSession'
import { realtimeClientTools, registerSessionStore } from './realtimeClientTools'
import { fetchVoiceToken } from '@/api/voice'
import type { VoiceSession, VoiceSessionConfig, ConversationStatus } from './types'
import type { VoiceSession, VoiceSessionConfig, ConversationStatus, StatusCallback } from './types'
import type { ApiClient } from '@/api/client'
import type { Session } from '@/types/api'
@@ -14,7 +14,7 @@ const DEBUG = import.meta.env.DEV
let conversationInstance: ReturnType<typeof useConversation> | 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<void>
@@ -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) => {
+2
View File
@@ -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