feat(conversation): OpenAI gpt-realtime-2.1 라이브 음성 대화 Premium 백엔드

- realtime-token Edge Function: 티어 검증 + realtime_session 쿼터 + ephemeral key 발급
- useRealtimeConversation 훅: WebRTC 직결 (마이크 → OpenAI, 오디오 자동 재생)
- VoiceConversationPage: conversationBackend 분기 + 연결 실패 시 로컬 fallback
- AppConfig.conversationBackend + SettingsModal 음성 대화 엔진 선택
- ErrorCode 799 ConversationRealtimeTokenFailed, i18n ko/en
This commit is contained in:
Yun Chan 2026-07-21 12:10:13 +09:00
parent 983c60cda2
commit 8f7d300b89
15 changed files with 762 additions and 40 deletions

View file

@ -5,8 +5,13 @@ import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors'
import { getVoiceConversationService } from '../services/VoiceConversationService'
import { getCloudSyncService } from '../services/CloudSyncService'
import { getLogger } from '../services/LoggerService'
import type { ConversationSendParams } from '@d3ro/core/types'
import type {
ConversationSendParams,
RealtimeTokenParams,
RealtimeTokenResult,
} from '@d3ro/core/types'
const logger = getLogger('voice-conversation-handlers')
@ -84,6 +89,47 @@ export function registerVoiceConversationHandlers(): void {
}
})
// OpenAI Realtime ephemeral token — 렌더러가 WebRTC 직결에 사용
ipcMain.handle(
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
async (_event, params: RealtimeTokenParams) => {
try {
const { data, error } = await getCloudSyncService().invokeFunction(
'realtime-token',
{ ...params },
)
if (error) {
return ipcError(ErrorCode.ConversationRealtimeTokenFailed, error.message)
}
const raw = data as {
value?: string
expires_at?: number
model?: string
tier?: string
error?: string
message?: string
}
if (!raw.value) {
return ipcError(
ErrorCode.ConversationRealtimeTokenFailed,
raw.error ?? raw.message ?? 'No token in response',
)
}
const result: RealtimeTokenResult = {
value: raw.value,
expiresAt: raw.expires_at ?? 0,
model: raw.model ?? 'unknown',
tier: (raw.tier as RealtimeTokenResult['tier']) ?? 'free',
}
return ipcSuccess(result)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error('Realtime token request failed:', msg)
return ipcError(ErrorCode.ConversationRealtimeTokenFailed, msg)
}
},
)
// finishListening — 렌더러에서 녹음 종료 버튼 클릭 시
ipcMain.handle('voiceConversation:finishListening', async () => {
try {

View file

@ -31,6 +31,8 @@ const CONFIG_DEFAULTS: AppConfig = {
// Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는
// 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요.
llmBackend: 'local' as const,
// 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC)
conversationBackend: 'local' as const,
defaultLLMAction: 'refine',
dictationShortcut: {
keyCode: 0xa5, // Right Alt

View file

@ -106,6 +106,8 @@ import type {
ConversationAssistantDelta,
ConversationAssistantMessage,
ConversationError,
RealtimeTokenParams,
RealtimeTokenResult,
// Phase 13.2
RAGDocument,
RAGQueryParams,
@ -615,6 +617,11 @@ const electronAPI = {
invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CLEAR_HISTORY),
cancelResponse: () =>
invoke<void>(IPC_CHANNELS.VOICE_CONVERSATION.CANCEL_RESPONSE),
getRealtimeToken: (params: RealtimeTokenParams) =>
invoke<RealtimeTokenResult>(
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
params
),
finishListening: () =>
invoke<void>('voiceConversation:finishListening'),
onStateChanged: (cb: (data: ConversationSessionInfo) => void): Unsubscribe =>

View file

@ -722,6 +722,37 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
: t('settings.backend.localHint')}
</Typography>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.conversationBackend')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.conversationBackend')}</InputLabel>
<Select
label={t('settings.conversationBackend')}
value={config.conversationBackend ?? 'local'}
onChange={(e) => {
const next = e.target.value
updateConfig('conversationBackend', next)
if (next === 'realtime') {
// Realtime은 로그인+구독 필요 — 라이선스 모달로 현재 티어 안내
window.dispatchEvent(new Event('d3ro:open-license-modal'))
}
}}
>
<MenuItem value="local">{t('settings.convBackend.local')}</MenuItem>
<MenuItem value="realtime">{t('settings.convBackend.realtime')}</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{config.conversationBackend === 'realtime'
? t('settings.convBackend.realtimeHint')
: t('settings.convBackend.localHint')}
</Typography>
{config.llmBackend !== 'premium' && (
<>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />

View file

@ -0,0 +1,306 @@
// src/renderer/hooks/useRealtimeConversation.ts
// OpenAI Realtime API (gpt-realtime-2.1) 라이브 음성 대화 훅.
// Supabase realtime-token(IPC 경유)으로 ephemeral key를 받아
// 렌더러가 OpenAI와 직접 WebRTC 연결한다 (마이크 캡처 + 오디오 재생 자동).
//
// 이벤트 매핑 (data channel 'oai-events'):
// response.created → thinking
// response.(output_)audio_transcript.delta → speaking + 스트리밍 텍스트
// conversation.item.input_audio_transcription.completed → 유저 메시지
// response.done → 어시스턴트 메시지 확정 + listening 복귀
import { useState, useRef, useCallback, useEffect } from 'react'
import type { ConversationState, ConversationMessage } from '@d3ro/core/types'
const REALTIME_CALLS_URL = 'https://api.openai.com/v1/realtime/calls'
const DATA_CHANNEL_NAME = 'oai-events'
export type RealtimeConnectionState = 'idle' | 'connecting' | 'live' | 'error'
interface RealtimeServerEvent {
type: string
delta?: string
transcript?: string
response?: { id?: string }
item_id?: string
error?: { message?: string }
}
export interface UseRealtimeConversationResult {
connectionState: RealtimeConnectionState
conversationState: ConversationState
messages: ConversationMessage[]
isActive: boolean
streamingText: string
streamingMsgId: string | null
error: string | null
/** 연결 시작. 실패 시 false (로컬 파이프라인 fallback 유도) */
start: () => Promise<boolean>
stop: () => void
sendText: (text: string) => void
cancelResponse: () => void
clearMessages: () => void
}
let messageSeq = 0
function nextMessageId(prefix: string): string {
messageSeq += 1
return `rt-${prefix}-${Date.now()}-${messageSeq}`
}
export function useRealtimeConversation(): UseRealtimeConversationResult {
const [connectionState, setConnectionState] = useState<RealtimeConnectionState>('idle')
const [conversationState, setConversationState] = useState<ConversationState>('idle')
const [messages, setMessages] = useState<ConversationMessage[]>([])
const [streamingText, setStreamingText] = useState('')
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const pcRef = useRef<RTCPeerConnection | null>(null)
const dcRef = useRef<RTCDataChannel | null>(null)
const micStreamRef = useRef<MediaStream | null>(null)
const audioElRef = useRef<HTMLAudioElement | null>(null)
const assistantBufferRef = useRef('')
const assistantMsgIdRef = useRef<string | null>(null)
const cleanup = useCallback(() => {
dcRef.current?.close()
dcRef.current = null
pcRef.current?.close()
pcRef.current = null
micStreamRef.current?.getTracks().forEach((track) => track.stop())
micStreamRef.current = null
if (audioElRef.current) {
audioElRef.current.srcObject = null
audioElRef.current.remove()
audioElRef.current = null
}
assistantBufferRef.current = ''
assistantMsgIdRef.current = null
}, [])
// 언마운트 시 연결 정리
useEffect(() => cleanup, [cleanup])
const flushAssistantMessage = useCallback(() => {
const content = assistantBufferRef.current.trim()
const msgId = assistantMsgIdRef.current
assistantBufferRef.current = ''
assistantMsgIdRef.current = null
setStreamingText('')
setStreamingMsgId(null)
if (content && msgId) {
setMessages((prev) => [
...prev,
{ id: msgId, role: 'assistant', content, timestamp: Date.now() },
])
}
}, [])
const handleServerEvent = useCallback(
(event: RealtimeServerEvent) => {
switch (event.type) {
case 'response.created': {
assistantBufferRef.current = ''
assistantMsgIdRef.current = nextMessageId('assistant')
setConversationState('thinking')
break
}
// GA/신규 이벤트명 모두 수용
case 'response.output_audio_transcript.delta':
case 'response.audio_transcript.delta':
case 'response.output_text.delta':
case 'response.text.delta': {
if (event.delta) {
assistantBufferRef.current += event.delta
setStreamingMsgId(assistantMsgIdRef.current)
setStreamingText(assistantBufferRef.current)
setConversationState('speaking')
}
break
}
case 'conversation.item.input_audio_transcription.completed': {
const transcript = (event.transcript ?? '').trim()
if (transcript) {
setMessages((prev) => [
...prev,
{
id: nextMessageId('user'),
role: 'user',
content: transcript,
timestamp: Date.now(),
},
])
}
break
}
case 'response.done': {
flushAssistantMessage()
setConversationState('listening')
break
}
case 'error': {
setError(event.error?.message ?? 'Realtime error')
break
}
default:
break
}
},
[flushAssistantMessage],
)
const start = useCallback(async (): Promise<boolean> => {
if (pcRef.current) return true
setError(null)
setConnectionState('connecting')
try {
// 1) ephemeral token (Supabase realtime-token → OpenAI client_secrets)
const tokenResult = await window.electronAPI.voiceConversation.getRealtimeToken({})
if (!tokenResult.success) {
throw new Error(tokenResult.error?.message ?? 'token request failed')
}
const { value: ephemeralKey, model } = tokenResult.data
// 2) 마이크 + peer connection
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
micStreamRef.current = micStream
const pc = new RTCPeerConnection()
pcRef.current = pc
const micTrack = micStream.getAudioTracks()[0]
if (micTrack) pc.addTrack(micTrack, micStream)
// 3) 원격 오디오 재생
const audioEl = document.createElement('audio')
audioEl.autoplay = true
audioElRef.current = audioEl
pc.ontrack = (e) => {
audioEl.srcObject = e.streams[0] ?? null
}
pc.onconnectionstatechange = () => {
const cs = pc.connectionState
if (cs === 'failed' || cs === 'disconnected' || cs === 'closed') {
setConnectionState((prev) => (prev === 'live' ? 'error' : prev))
}
}
// 4) 이벤트 데이터 채널
const dc = pc.createDataChannel(DATA_CHANNEL_NAME)
dcRef.current = dc
dc.addEventListener('message', (e: MessageEvent) => {
try {
handleServerEvent(JSON.parse(e.data as string) as RealtimeServerEvent)
} catch {
// 파싱 불가 이벤트 무시
}
})
dc.addEventListener('open', () => {
// 유저 발화 전사 활성화 — 미지원 모델이면 error 이벤트만 오고 대화는 유지됨
dc.send(
JSON.stringify({
type: 'session.update',
session: {
type: 'realtime',
audio: {
input: { transcription: { model: 'whisper-1' } },
},
},
}),
)
})
// 5) SDP 교환
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const sdpResponse = await fetch(`${REALTIME_CALLS_URL}?model=${encodeURIComponent(model)}`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer ${ephemeralKey}`,
'Content-Type': 'application/sdp',
},
})
if (!sdpResponse.ok) {
const text = await sdpResponse.text()
throw new Error(`SDP exchange failed (${sdpResponse.status}): ${text.slice(0, 200)}`)
}
const answerSdp = await sdpResponse.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
setConnectionState('live')
setConversationState('listening')
return true
} catch (err) {
cleanup()
setConnectionState('error')
setConversationState('idle')
setError(err instanceof Error ? err.message : String(err))
return false
}
}, [cleanup, handleServerEvent])
const stop = useCallback(() => {
flushAssistantMessage()
cleanup()
setConnectionState('idle')
setConversationState('idle')
}, [cleanup, flushAssistantMessage])
const sendText = useCallback((text: string) => {
const dc = dcRef.current
if (!dc || dc.readyState !== 'open') return
setMessages((prev) => [
...prev,
{ id: nextMessageId('user'), role: 'user', content: text, timestamp: Date.now() },
])
dc.send(
JSON.stringify({
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text }],
},
}),
)
dc.send(JSON.stringify({ type: 'response.create' }))
}, [])
const cancelResponse = useCallback(() => {
const dc = dcRef.current
if (!dc || dc.readyState !== 'open') return
dc.send(JSON.stringify({ type: 'response.cancel' }))
flushAssistantMessage()
setConversationState('listening')
}, [flushAssistantMessage])
const clearMessages = useCallback(() => {
setMessages([])
}, [])
return {
connectionState,
conversationState,
messages,
isActive: connectionState === 'connecting' || connectionState === 'live',
streamingText,
streamingMsgId,
error,
start,
stop,
sendText,
cancelResponse,
clearMessages,
}
}

View file

@ -12,6 +12,7 @@ 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 { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel'
import { useRealtimeConversation } from '../hooks/useRealtimeConversation'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
@ -24,41 +25,68 @@ import type {
export function VoiceConversationPage(): React.ReactElement {
const { t } = useI18n()
const [state, setState] = useState<ConversationState>('idle')
const [messages, setMessages] = useState<ConversationMessage[]>([])
const [isActive, setIsActive] = useState(false)
const [streamingText, setStreamingText] = useState('')
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
const [localState, setLocalState] = useState<ConversationState>('idle')
const [localMessages, setLocalMessages] = useState<ConversationMessage[]>([])
const [localActive, setLocalActive] = useState(false)
const [localStreamingText, setLocalStreamingText] = useState('')
const [localStreamingMsgId, setLocalStreamingMsgId] = useState<string | null>(null)
const [textInput, setTextInput] = useState('')
const [errorBanner, setErrorBanner] = useState<ConversationError | null>(null)
const [backend, setBackend] = useState<'local' | 'realtime'>('local')
const messagesEndRef = useRef<HTMLDivElement>(null)
const realtime = useRealtimeConversation()
const isRealtime = backend === 'realtime'
// 표시용 상태 — 백엔드에 따라 로컬 파이프라인(IPC) vs Realtime 훅
const state = isRealtime ? realtime.conversationState : localState
const messages = isRealtime ? realtime.messages : localMessages
const isActive = isRealtime ? realtime.isActive : localActive
const streamingText = isRealtime ? realtime.streamingText : localStreamingText
const streamingMsgId = isRealtime ? realtime.streamingMsgId : localStreamingMsgId
// 설정에서 대화 백엔드 로드
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success && r.data.conversationBackend === 'realtime') {
setBackend('realtime')
}
})
}, [])
// Realtime 에러 → 배너
useEffect(() => {
if (realtime.error) {
setErrorBanner({ phase: 'llm', message: realtime.error })
}
}, [realtime.error])
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
// IPC 이벤트 구독
// IPC 이벤트 구독 (로컬 파이프라인)
useEffect(() => {
const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => {
setState(info.state)
setMessages(info.messages)
setIsActive(info.isActive)
setLocalState(info.state)
setLocalMessages(info.messages)
setLocalActive(info.isActive)
})
const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => {
setMessages((prev) => [...prev, msg])
setStreamingText('')
setStreamingMsgId(null)
setLocalMessages((prev) => [...prev, msg])
setLocalStreamingText('')
setLocalStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => {
setStreamingMsgId(data.messageId)
setStreamingText(data.accumulated)
setLocalStreamingMsgId(data.messageId)
setLocalStreamingText(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)
setLocalMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }])
setLocalStreamingText('')
setLocalStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => {
@ -69,9 +97,9 @@ export function VoiceConversationPage(): React.ReactElement {
// 초기 상태 로드
window.electronAPI.voiceConversation.getState().then((r) => {
if (r.success) {
setState(r.data.state)
setMessages(r.data.messages)
setIsActive(r.data.isActive)
setLocalState(r.data.state)
setLocalMessages(r.data.messages)
setLocalActive(r.data.isActive)
}
})
@ -84,36 +112,78 @@ export function VoiceConversationPage(): React.ReactElement {
}
}, [scrollToBottom])
// Realtime 메시지 갱신 시 스크롤
useEffect(() => {
if (isRealtime) setTimeout(scrollToBottom, 50)
}, [isRealtime, realtime.messages, realtime.streamingText, scrollToBottom])
const handleStartSession = useCallback(async () => {
if (isRealtime) {
const ok = await realtime.start()
if (!ok) {
// Realtime 연결 실패 → 로컬 파이프라인 자동 fallback
setBackend('local')
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
await window.electronAPI.voiceConversation.startSession()
}
return
}
await window.electronAPI.voiceConversation.startSession()
}, [])
}, [isRealtime, realtime, t])
const handleStopSession = useCallback(async () => {
if (isRealtime) {
realtime.stop()
return
}
await window.electronAPI.voiceConversation.stopSession()
}, [])
}, [isRealtime, realtime])
const handleFinishListening = useCallback(async () => {
if (isRealtime) return // server VAD가 자동 처리
await window.electronAPI.voiceConversation.finishListening()
}, [])
}, [isRealtime])
const handleSendText = useCallback(async () => {
if (!textInput.trim()) return
const text = textInput.trim()
setTextInput('')
if (isRealtime) {
if (!realtime.isActive) {
const ok = await realtime.start()
if (!ok) {
setBackend('local')
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
await window.electronAPI.voiceConversation.startSession()
await window.electronAPI.voiceConversation.sendMessage({ text })
return
}
}
realtime.sendText(text)
return
}
if (!isActive) {
await window.electronAPI.voiceConversation.startSession()
}
await window.electronAPI.voiceConversation.sendMessage({ text })
}, [textInput, isActive])
}, [textInput, isActive, isRealtime, realtime, t])
const handleClearHistory = useCallback(async () => {
if (isRealtime) {
realtime.clearMessages()
return
}
await window.electronAPI.voiceConversation.clearHistory()
setMessages([])
}, [])
setLocalMessages([])
}, [isRealtime, realtime])
const handleCancelResponse = useCallback(async () => {
if (isRealtime) {
realtime.cancelResponse()
return
}
await window.electronAPI.voiceConversation.cancelResponse()
}, [])
}, [isRealtime, realtime])
const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
@ -130,6 +200,11 @@ export function VoiceConversationPage(): React.ReactElement {
speaking: t('conversation.speaking'),
}
const displayStateLabel =
isRealtime && realtime.connectionState === 'connecting'
? t('conversation.realtime.connecting')
: stateLabel[state]
const formatErrorMessage = useCallback((err: ConversationError): string => {
// Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label.
if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) {
@ -152,9 +227,23 @@ export function VoiceConversationPage(): React.ReactElement {
title={t('conversation.title').toUpperCase()}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{isRealtime && (
<PhosphorText
variant="micro"
sx={{
color: d3roPalette.accent.amber,
border: `1px solid ${d3roPalette.accent.amber}`,
borderRadius: '3px',
px: 0.5,
lineHeight: 1.6,
}}
>
{t('conversation.realtime.badge')}
</PhosphorText>
)}
<Led color={stateLedColor[state]} pulse={state === 'listening' || state === 'thinking'} size={8} />
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{stateLabel[state].toUpperCase()}
{displayStateLabel.toUpperCase()}
</PhosphorText>
{messages.length > 0 && (
<Tooltip title={t('conversation.clearHistory')}>
@ -167,8 +256,8 @@ export function VoiceConversationPage(): React.ReactElement {
}
/>
{/* 메시지 영역 — state 분기: listening=몰입 패널, 그 외=메시지 리스트 */}
{state === 'listening' ? (
{/* 메시지 영역 — 로컬 파이프라인의 listening만 몰입 패널 (Realtime은 항상 채팅 뷰) */}
{!isRealtime && state === 'listening' ? (
<Box
sx={{
flex: 1,
@ -292,7 +381,11 @@ export function VoiceConversationPage(): React.ReactElement {
<MicIcon sx={{ fontSize: 20 }} />
</PhysicalButton>
) : state === 'listening' ? (
<PhysicalButton selected onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
<PhysicalButton
selected
onClick={isRealtime ? handleStopSession : handleFinishListening}
sx={{ minWidth: 48, px: 2 }}
>
<StopIcon sx={{ fontSize: 20 }} />
</PhysicalButton>
) : state === 'thinking' || state === 'speaking' ? (
@ -305,8 +398,8 @@ export function VoiceConversationPage(): React.ReactElement {
</PhysicalButton>
)}
{/* 텍스트 입력 — listening 시 숨김, thinking/speaking 시 disabled */}
{state !== 'listening' && (
{/* 텍스트 입력 — 로컬 listening 시 숨김 (Realtime은 항상 표시), thinking/speaking 시 disabled */}
{(isRealtime || state !== 'listening') && (
<TextField
value={state === 'thinking' || state === 'speaking' ? '' : textInput}
onChange={(e) => setTextInput(e.target.value)}
@ -335,8 +428,8 @@ export function VoiceConversationPage(): React.ReactElement {
/>
)}
{/* 전송 버튼 — listening 시 숨김 */}
{state !== 'listening' && (
{/* 전송 버튼 — 로컬 listening 시 숨김 */}
{(isRealtime || state !== 'listening') && (
<IconButton
onClick={handleSendText}
disabled={!textInput.trim() || state === 'thinking' || state === 'speaking'}