feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동

- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,291 @@
// 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 '../components/ds'
import { PageHeader } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type {
ConversationState,
ConversationMessage,
ConversationAssistantDelta,
} from '@shared/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>
)
}