fix(desktop): Bug 13 전면 해소 — Conversation 에러 배너 + Dictation 빈 STT 가드 + recording tip 타이머 leak

- VoiceConversationPage: Snackbar + Alert(severity=warning filled) 하단 중앙
  배너 추가, onError 콜백에서 setErrorBanner. formatErrorMessage 헬퍼 —
  phase=stt + 'no speech' → i18n conversation.error.noSpeech, 그 외 →
  phaseLabel: rawMessage.
- VoiceModeService._transcribe: minBytes 가드(0.5s @ 16kHz 16bit mono =
  16000B) + 빈 result.text 가드. 양쪽 모두 _handleError(STTAudioTooShort /
  STTNoAudioData, 'No speech detected...')로 recording-tip popup error
  상태 3초 표시 재사용. 기존에는 빈 전사가 조용히 session completed +
  빈 history entry 생성되던 경로 차단.
- VoiceModeService._errorHideTimer 필드: _handleError의 setTimeout(hide, 3000)
  핸들 보관, _startSession 초기화 블록과 dispose()에서 clearTimeout. 다음
  세션 시작 후에도 이전 에러 타이머가 살아 진행 중인 recording tip을 ~2초
  지점에 숨기던 잠재 버그 수정 (실측 재현 및 해소 확인).
- i18n ko/en: conversation.error.phase.{stt,llm,tts} + conversation.error.noSpeech
  4개 키 추가.
This commit is contained in:
윤찬 2026-04-12 09:46:22 +09:00
parent 16aaf37956
commit a744551442
4 changed files with 87 additions and 5 deletions

View file

@ -3,7 +3,7 @@
// STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼.
import { useState, useEffect, useCallback, useRef } from 'react'
import { Box, IconButton, TextField, Tooltip } from '@mui/material'
import { Alert, Box, IconButton, Snackbar, TextField, Tooltip } from '@mui/material'
import MicIcon from '@mui/icons-material/Mic'
import StopIcon from '@mui/icons-material/Stop'
import SendIcon from '@mui/icons-material/Send'
@ -19,6 +19,7 @@ import type {
ConversationState,
ConversationMessage,
ConversationAssistantDelta,
ConversationError,
} from '@d3ro/core/types'
export function VoiceConversationPage(): React.ReactElement {
@ -29,6 +30,7 @@ export function VoiceConversationPage(): React.ReactElement {
const [streamingText, setStreamingText] = useState('')
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
const [textInput, setTextInput] = useState('')
const [errorBanner, setErrorBanner] = useState<ConversationError | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = useCallback(() => {
@ -59,8 +61,9 @@ export function VoiceConversationPage(): React.ReactElement {
setStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubError = window.electronAPI.voiceConversation.onError(() => {
// 에러 시 자동 복구 (서비스에서 listening으로 전환)
const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => {
// 서비스는 자동 복구(listening 재진입)까지 처리. 여기선 배너만 띄운다.
setErrorBanner(err)
})
// 초기 상태 로드
@ -127,6 +130,15 @@ export function VoiceConversationPage(): React.ReactElement {
speaking: t('conversation.speaking'),
}
const formatErrorMessage = useCallback((err: ConversationError): string => {
// Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label.
if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) {
return t('conversation.error.noSpeech')
}
const phaseLabel = t(`conversation.error.phase.${err.phase}`)
return `${phaseLabel}: ${err.message}`
}, [t])
const stateLedColor = {
idle: 'amber' as const,
listening: 'red' as const,
@ -346,6 +358,23 @@ export function VoiceConversationPage(): React.ReactElement {
)}
</Box>
</MetalCard>
{/* 에러 배너 — Bug 13 "No speech detected" 등 사용자 피드백 */}
<Snackbar
open={errorBanner !== null}
autoHideDuration={4000}
onClose={() => setErrorBanner(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="warning"
variant="filled"
onClose={() => setErrorBanner(null)}
sx={{ width: '100%' }}
>
{errorBanner ? formatErrorMessage(errorBanner) : ''}
</Alert>
</Snackbar>
</Box>
)
}