mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Add error hint
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '离线',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user