packages/ui (@d3ro/ui) 신규: - src/theme.ts (d3roPalette/d3roTypo/d3roShadow/d3roRadius SSOT) - src/theme-vars.ts (팝업/main 프로세스용 CSS 변수 맵) - src/components/ds/ (CrtDisplay, InstrumentPanel, Led, MetalCard, MetalDial, PhosphorText, PhysicalButton, ScreenPanel, ButtonGroup) - src/index.ts barrel - subpath exports: ./theme, ./theme-vars, ./components/ds - React/MUI/Emotion은 peerDependencies로 선언 - @d3ro/core만 직접 의존성 apps/desktop/src/shared/ 디렉토리 완전 제거: - theme-vars가 마지막 남은 파일이었음 - tsconfig include에서 src/shared/**/* 제거 일괄 치환 (renderer 전역): - ../theme, ../../theme, ./theme → @d3ro/ui/theme - ../components/ds, ../../components/ds, ./ds, ../ds → @d3ro/ui/components/ds - ../ds/<Component>, ../../ds/<Component> → @d3ro/ui/components/ds (세부 파일 import는 barrel로 통합) - @shared/theme-vars → @d3ro/ui/theme-vars (WindowManager) apps/desktop 설정: - package.json: @d3ro/ui: '*' dep 추가 - tsconfig.node/web.json: @shared/* paths 완전 제거, @d3ro/ui, @d3ro/ui/* paths 추가 - electron.vite.config.ts: @shared alias 제거, @d3ro/ui alias 추가, externalize exclude에 @d3ro/ui 추가 - vitest.config.ts: alias 교체 DS 컴포넌트 내부의 '../../theme' 상대 경로는 packages/ui 구조에서 동일하게 해결되어 그대로 유효. 검증: typecheck + build + dev 런타임 모두 통과.
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
// 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<ConversationState>('idle')
|
|
const [messages, setMessages] = useState<ConversationMessage[]>([])
|
|
const [isActive, setIsActive] = useState(false)
|
|
const [streamingText, setStreamingText] = useState('')
|
|
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
|
|
const [textInput, setTextInput] = useState('')
|
|
const messagesEndRef = useRef<HTMLDivElement>(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 (
|
|
<Box sx={{ maxWidth: 800, mx: 'auto', p: 4, pb: 8, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
<PageHeader
|
|
title={t('conversation.title').toUpperCase()}
|
|
action={
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<Led color={stateLedColor[state]} pulse={state === 'listening' || state === 'thinking'} size={8} />
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
{stateLabel[state].toUpperCase()}
|
|
</PhosphorText>
|
|
{messages.length > 0 && (
|
|
<Tooltip title={t('conversation.clearHistory')}>
|
|
<IconButton size="small" onClick={handleClearHistory} sx={{ color: d3roPalette.text.inactive }}>
|
|
<DeleteSweepIcon sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
{/* 메시지 목록 */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
overflow: 'auto',
|
|
mt: 2,
|
|
mb: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1.5,
|
|
}}
|
|
>
|
|
{messages.length === 0 && !streamingText && (
|
|
<Box sx={{ textAlign: 'center', mt: 8 }}>
|
|
<PhosphorText variant="heading" sx={{ color: d3roPalette.text.inactive, mb: 1 }}>
|
|
{t('conversation.empty')}
|
|
</PhosphorText>
|
|
<PhosphorText variant="dim">
|
|
{t('conversation.emptyHint')}
|
|
</PhosphorText>
|
|
</Box>
|
|
)}
|
|
|
|
{messages.map((msg) => (
|
|
<Box
|
|
key={msg.id}
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
}}
|
|
>
|
|
<MetalCard
|
|
sx={{
|
|
maxWidth: '75%',
|
|
...(msg.role === 'user' && {
|
|
bgcolor: d3roPalette.accent.amber,
|
|
'& *': { color: `${d3roPalette.bg.chassis} !important` },
|
|
}),
|
|
}}
|
|
>
|
|
<PhosphorText
|
|
variant="compact"
|
|
sx={{
|
|
whiteSpace: 'pre-wrap',
|
|
lineHeight: 1.6,
|
|
}}
|
|
>
|
|
{msg.content}
|
|
</PhosphorText>
|
|
</MetalCard>
|
|
</Box>
|
|
))}
|
|
|
|
{/* 스트리밍 중인 어시스턴트 메시지 */}
|
|
{streamingText && streamingMsgId && (
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
|
<MetalCard sx={{ maxWidth: '75%' }}>
|
|
<PhosphorText variant="compact" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
|
{streamingText}
|
|
<Box component="span" sx={{ animation: 'blink 1s infinite', color: d3roPalette.accent.amber }}>
|
|
{'▌'}
|
|
</Box>
|
|
</PhosphorText>
|
|
</MetalCard>
|
|
</Box>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</Box>
|
|
|
|
{/* 하단 컨트롤 바 */}
|
|
<MetalCard>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
{/* 녹음 버튼 */}
|
|
{!isActive ? (
|
|
<PhysicalButton onClick={handleStartSession} sx={{ minWidth: 48, px: 2 }}>
|
|
<MicIcon sx={{ fontSize: 20 }} />
|
|
</PhysicalButton>
|
|
) : state === 'listening' ? (
|
|
<PhysicalButton selected onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
|
|
<StopIcon sx={{ fontSize: 20 }} />
|
|
</PhysicalButton>
|
|
) : state === 'thinking' || state === 'speaking' ? (
|
|
<PhysicalButton onClick={handleCancelResponse} sx={{ minWidth: 48, px: 2 }}>
|
|
<CancelIcon sx={{ fontSize: 20 }} />
|
|
</PhysicalButton>
|
|
) : (
|
|
<PhysicalButton onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
|
|
<MicIcon sx={{ fontSize: 20 }} />
|
|
</PhysicalButton>
|
|
)}
|
|
|
|
{/* 텍스트 입력 */}
|
|
<TextField
|
|
value={textInput}
|
|
onChange={(e) => 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,
|
|
},
|
|
}}
|
|
/>
|
|
|
|
{/* 전송 버튼 */}
|
|
<IconButton
|
|
onClick={handleSendText}
|
|
disabled={!textInput.trim()}
|
|
sx={{
|
|
color: textInput.trim() ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
|
}}
|
|
>
|
|
<SendIcon sx={{ fontSize: 20 }} />
|
|
</IconButton>
|
|
|
|
{/* 세션 종료 */}
|
|
{isActive && (
|
|
<PhysicalButton onClick={handleStopSession} sx={{ minWidth: 48, px: 1 }}>
|
|
<PhosphorText variant="micro">{t('conversation.end')}</PhosphorText>
|
|
</PhysicalButton>
|
|
)}
|
|
</Box>
|
|
</MetalCard>
|
|
</Box>
|
|
)
|
|
}
|