// src/renderer/pages/VoiceConversationPage.tsx // Phase 13.1: 음성 대화 모드 UI // STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼. import { useState, useEffect, useCallback, useRef } from 'react' import { Box, IconButton, 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' import DeleteSweepIcon from '@mui/icons-material/DeleteSweep' import CancelIcon from '@mui/icons-material/Cancel' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds' import { PageHeader } from '../components/shared' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '../i18n' import type { ConversationState, ConversationMessage, ConversationAssistantDelta, } from '@d3ro/core/types' export function VoiceConversationPage(): React.ReactElement { const { t } = useI18n() const [state, setState] = useState('idle') const [messages, setMessages] = useState([]) const [isActive, setIsActive] = useState(false) const [streamingText, setStreamingText] = useState('') const [streamingMsgId, setStreamingMsgId] = useState(null) const [textInput, setTextInput] = useState('') const messagesEndRef = useRef(null) const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, []) // IPC 이벤트 구독 useEffect(() => { const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => { setState(info.state) setMessages(info.messages) setIsActive(info.isActive) }) const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => { setMessages((prev) => [...prev, msg]) setStreamingText('') setStreamingMsgId(null) setTimeout(scrollToBottom, 50) }) const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => { setStreamingMsgId(data.messageId) setStreamingText(data.accumulated) setTimeout(scrollToBottom, 50) }) const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => { setMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }]) setStreamingText('') setStreamingMsgId(null) setTimeout(scrollToBottom, 50) }) const unsubError = window.electronAPI.voiceConversation.onError(() => { // 에러 시 자동 복구 (서비스에서 listening으로 전환) }) // 초기 상태 로드 window.electronAPI.voiceConversation.getState().then((r) => { if (r.success) { setState(r.data.state) setMessages(r.data.messages) setIsActive(r.data.isActive) } }) return () => { unsubState() unsubUser() unsubDelta() unsubComplete() unsubError() } }, [scrollToBottom]) const handleStartSession = useCallback(async () => { await window.electronAPI.voiceConversation.startSession() }, []) const handleStopSession = useCallback(async () => { await window.electronAPI.voiceConversation.stopSession() }, []) const handleFinishListening = useCallback(async () => { await window.electronAPI.voiceConversation.finishListening() }, []) const handleSendText = useCallback(async () => { if (!textInput.trim()) return const text = textInput.trim() setTextInput('') if (!isActive) { await window.electronAPI.voiceConversation.startSession() } await window.electronAPI.voiceConversation.sendMessage({ text }) }, [textInput, isActive]) const handleClearHistory = useCallback(async () => { await window.electronAPI.voiceConversation.clearHistory() setMessages([]) }, []) const handleCancelResponse = useCallback(async () => { await window.electronAPI.voiceConversation.cancelResponse() }, []) const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSendText() } }, [handleSendText]) const stateLabel = { idle: t('conversation.idle'), listening: t('conversation.listening'), thinking: t('conversation.thinking'), speaking: t('conversation.speaking'), } const stateLedColor = { idle: 'amber' as const, listening: 'red' as const, thinking: 'amber' as const, speaking: 'green' as const, } return ( {stateLabel[state].toUpperCase()} {messages.length > 0 && ( )} } /> {/* 메시지 목록 */} {messages.length === 0 && !streamingText && ( {t('conversation.empty')} {t('conversation.emptyHint')} )} {messages.map((msg) => ( {msg.content} ))} {/* 스트리밍 중인 어시스턴트 메시지 */} {streamingText && streamingMsgId && ( {streamingText} {'▌'} )}
{/* 하단 컨트롤 바 */} {/* 녹음 버튼 */} {!isActive ? ( ) : state === 'listening' ? ( ) : state === 'thinking' || state === 'speaking' ? ( ) : ( )} {/* 텍스트 입력 */} setTextInput(e.target.value)} onKeyDown={handleTextKeyDown} placeholder={t('conversation.inputPlaceholder')} size="small" fullWidth sx={{ '& .MuiOutlinedInput-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, bgcolor: d3roPalette.bg.inset, '& fieldset': { borderColor: d3roPalette.border.subtle }, '&:hover fieldset': { borderColor: d3roPalette.accent.amber }, '&.Mui-focused fieldset': { borderColor: d3roPalette.accent.amber }, }, '& .MuiOutlinedInput-input': { color: d3roPalette.text.primary, }, }} /> {/* 전송 버튼 */} {/* 세션 종료 */} {isActive && ( {t('conversation.end')} )} ) }