// src/renderer/pages/MeetingModePage.tsx // High-End Meeting Intelligence Studio: Live Transcribing & On-the-fly Editing + Context Enricher Memos import React, { useState, useEffect, useCallback, useRef } from 'react' import { Box, LinearProgress, TextField, Snackbar, Alert, Typography, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divider, CircularProgress, } from '@mui/material' import { Plus, Square, Circle, Users, Clock, FileText, Sparkles, Mic, Tag, CheckCircle2, ListTodo, Lightbulb, AlertTriangle, AlertCircle, Settings, RefreshCw, Cpu, } from 'lucide-react' import { MetalCard, PhosphorText, PhysicalButton, ScreenPanel, DoubleBezelCard, TactileBadge, AudioVisualizerBar, } from '@d3ro/ui/components/ds' import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs' import { EditableSegment } from '../components/meeting/EditableSegment' import { PageHeader, EmptyStateCard } from '../components/shared' import { isImeComposingEvent } from '../utils/keyboard' import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { MeetingSessionSummary, MeetingSessionDetail, MeetingModeStateInfo, MeetingProcessingProgress, CaptionSegment, MeetingMemo, } from '@d3ro/core/types' type MeetingView = 'list' | 'recording' | 'detail' interface MeetingErrorInfo { type: 'collision' | 'stt' | 'audio' | 'general' title: string code?: number | string message: string reason: string suggestion: string } const CONTEXT_TAGS = [ { label: '#결정', icon: , color: d3roPalette.tag.green }, { label: '#할일', icon: , color: d3roPalette.accent.light }, { label: '#안건', icon: , color: d3roPalette.tag.orange }, { label: '#아이디어', icon: , color: d3roPalette.tag.purple }, { label: '#이슈', icon: , color: d3roPalette.tag.red }, ] export function MeetingModePage(): React.ReactElement { const { t } = useI18n() const [view, setView] = useState('list') const [sessions, setSessions] = useState([]) const [totalSessions, setTotalSessions] = useState(0) const [loading, setLoading] = useState(true) const [page] = useState(1) const pageSize = 20 // 사전 점검 및 기동 상태 const [isStarting, setIsStarting] = useState(false) const [errorInfo, setErrorInfo] = useState(null) const [sttProvider, setSttProvider] = useState('local') const [, setSttEngineState] = useState('ready') const [micDeviceName, setMicDeviceName] = useState('') // 녹음 뷰 상태 const [stateInfo, setStateInfo] = useState(null) const [segments, setSegments] = useState([]) const [memos, setMemos] = useState([]) const [memoInput, setMemoInput] = useState('') const [elapsedMs, setElapsedMs] = useState(0) const [progress, setProgress] = useState(null) const [audioLevel, setAudioLevel] = useState(0) const transcriptRef = useRef(null) const elapsedTimerRef = useRef | null>(null) const recordingStartRef = useRef(0) // 상세 뷰 상태 const [detail, setDetail] = useState(null) const [snackbarMsg, setSnackbarMsg] = useState(null) // ── 사전 환경 점검 로드 ── const checkPreflight = useCallback(async () => { try { const st = await window.electronAPI.stt.getStatus() if (st.success && st.data) { setSttEngineState(st.data.engineState) } const provRes = await window.electronAPI.config.get({ key: 'sttProvider' }) if (provRes.success && provRes.data) { setSttProvider(String(provRes.data)) } const devsRes = await window.electronAPI.audio.getDevices() if (devsRes.success && devsRes.data && devsRes.data.length > 0) { const def = devsRes.data.find((d) => d.isDefault) || devsRes.data[0] setMicDeviceName(def.label || '기본 마이크') } } catch { /* ignore */ } }, []) useEffect(() => { checkPreflight() }, [checkPreflight]) // ── 에러 파서 ── const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => { const msg = (errMessage ?? '').toLowerCase() const code = errCode if (code === 1400 || msg.includes('활성') || msg.includes('자막') || msg.includes('이미') || msg.includes('already') || msg.includes('collision')) { return { type: 'collision', title: '이전 회의 또는 실시간 자막 충돌', code: code ?? 'SESSION_COLLISION', message: errMessage || '이전 회의 세션 또는 자막 모드가 백그라운드에 여전히 활성화되어 있습니다.', reason: '백그라운드에서 동작 중이던 회의/자막 프로세스가 완전히 해제되지 않아 충돌했습니다.', suggestion: '기존 프로세스를 강제 초기화한 후 즉시 새 회의를 시작할 수 있습니다.', } } if (code === 101 || (code !== undefined && Number(code) >= 100 && Number(code) <= 120) || msg.includes('stt') || msg.includes('whisper') || msg.includes('sidecar') || msg.includes('python') || msg.includes('model')) { return { type: 'stt', title: '음성 인식(STT) 엔진 준비 실패', code: code ?? 'STT_UNAVAILABLE', message: errMessage || 'STT 음성 인식 엔진 또는 Whisper 모델 로드에 실패했습니다.', reason: '로컬 Whisper 모델 파일이 준비되지 않았거나, 사이드카 프로세스 포트가 점유되었습니다.', suggestion: 'STT 설정에서 모델 다운로드 상태를 확인하거나 강제 초기화 후 다시 시도하세요.', } } if ((code !== undefined && Number(code) >= 200 && Number(code) <= 220) || msg.includes('audio') || msg.includes('mic') || msg.includes('장치') || msg.includes('sox')) { return { type: 'audio', title: '마이크 캡처 시작 실패', code: code ?? 'MIC_CAPTURE_FAILED', message: errMessage || '마이크 오디오 스트림을 시작할 수 없습니다.', reason: '기본 마이크 입력 장치를 찾을 수 없거나 Windows 마이크 권한이 비활성화되어 있습니다.', suggestion: '오디오 설정에서 마이크 장치를 선택하거나 기본 장치로 재시도하세요.', } } return { type: 'general', title: '회의를 시작할 수 없습니다', code: code ?? 'MEETING_START_ERROR', message: errMessage || '알 수 없는 오류로 회의 녹음을 시작하지 못했습니다.', reason: '백그라운드 통신 또는 초기화 중 문제가 발생했습니다.', suggestion: '프로세스를 강제 초기화한 후 다시 시작해 보세요.', } }, []) // ── 세션 목록 로드 ── const loadSessions = useCallback(async () => { setLoading(true) const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize }) if (resp.success) { setSessions(resp.data.sessions) setTotalSessions(resp.data.total) } setLoading(false) }, [page]) useEffect(() => { if (view === 'list') loadSessions() }, [view, loadSessions]) // ── IPC 이벤트 구독 ── useEffect(() => { const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => { setStateInfo(info) if (info.state === 'idle' && view === 'recording') { setProgress(null) setView('list') } }) const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => { setSegments((prev) => [...prev, seg]) setTimeout(() => { if (transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight } }, 50) }) const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => { setProgress(p) }) const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => { setProgress(null) setView('list') }) const unsubError = window.electronAPI.meetingMode.onError((e) => { setProgress(null) setView('list') const parsed = parseMeetingError(e.message, e.code) setErrorInfo(parsed) }) const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => { setAudioLevel(Math.min(data.level * 10, 1)) }) return () => { unsubState() unsubSegment() unsubProgress() unsubCompleted() unsubError() unsubAudioLevel() } }, [view, parseMeetingError]) // ── 경과 시간 타이머 ── useEffect(() => { if (view === 'recording') { recordingStartRef.current = Date.now() elapsedTimerRef.current = setInterval(() => { setElapsedMs(Date.now() - recordingStartRef.current) }, 1000) } return () => { if (elapsedTimerRef.current) { clearInterval(elapsedTimerRef.current) elapsedTimerRef.current = null } } }, [view]) // ── 핸들러 ── const handleStartRecording = useCallback(async (force = false) => { setIsStarting(true) setErrorInfo(null) try { const resp = await window.electronAPI.meetingMode.startRecording({ force }) if (resp.success) { setSegments([]) setMemos([]) setElapsedMs(0) setProgress(null) setView('recording') } else { const parsed = parseMeetingError(resp.error?.message, resp.error?.code) setErrorInfo(parsed) } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) const parsed = parseMeetingError(msg) setErrorInfo(parsed) } finally { setIsStarting(false) } }, [parseMeetingError]) const handleStopRecording = useCallback(async () => { await window.electronAPI.meetingMode.stopRecording() }, []) // ── 실시간 전사 세그먼트 즉시 수정 핸들러 ── const handleLiveEditSegment = useCallback( async (segmentId: string, newText: string) => { setSegments((prev) => prev.map((s) => (s.id === segmentId ? { ...s, text: newText } : s)), ) if (stateInfo?.sessionId) { await window.electronAPI.meetingMode.editSegment({ sessionId: stateInfo.sessionId, segmentId, text: newText, }) } }, [stateInfo?.sessionId], ) const isAddingMemoRef = useRef(false) const isViewingDetailRef = useRef(false) const handleAddMemo = useCallback(async () => { if (!memoInput.trim() || isAddingMemoRef.current) return isAddingMemoRef.current = true const text = memoInput.trim() setMemoInput('') try { const resp = await window.electronAPI.meetingMode.addMemo({ content: text }) if (resp.success) { setMemos((prev) => [...prev, resp.data]) } } finally { isAddingMemoRef.current = false } }, [memoInput]) const handleTagClick = (tag: string) => { setMemoInput((prev) => (prev ? `${tag} ${prev}` : `${tag} `)) } const handleMemoKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleAddMemo() } }, [handleAddMemo], ) const handleViewDetail = useCallback( async (sessionId: string) => { if (isViewingDetailRef.current) return isViewingDetailRef.current = true try { const resp = await window.electronAPI.meetingMode.getSession({ sessionId }) if (resp.success) { setDetail(resp.data) setView('detail') } else { setSnackbarMsg(t('meeting.viewDetailError')) } } finally { isViewingDetailRef.current = false } }, [t], ) const formatTime = (ms: number): string => { const totalSec = Math.floor(ms / 1000) const min = Math.floor(totalSec / 60) const sec = totalSec % 60 return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}` } const formatDate = (ts: number): string => { const d = new Date(ts) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } const statusTone = (status: string): 'success' | 'warning' | 'error' | 'accent' => { switch (status) { case 'completed': return 'success' case 'recording': return 'error' case 'processing': return 'warning' case 'error': return 'error' default: return 'accent' } } const snackbar = ( setSnackbarMsg(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbarMsg(null)} sx={{ width: '100%' }}> {snackbarMsg} ) const errorDialog = ( setErrorInfo(null)} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, borderRadius: d3roRadius.doubleBezelOuter, border: `1px solid ${d3roPalette.tag.red}`, boxShadow: d3roShadow.dialog, p: 1.5, }, }} > {errorInfo?.title} {errorInfo?.code ? `ERR: ${errorInfo.code}` : 'ERROR'} Meeting Intelligence Diagnostics {errorInfo?.message} 발생 원인 {errorInfo?.reason} 해결 조치 {errorInfo?.suggestion} {errorInfo?.type === 'stt' && ( { setErrorInfo(null) window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } })) }} leadingIcon={} > STT / 모델 설정 열기 )} {errorInfo?.type === 'audio' && ( { setErrorInfo(null) window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } })) }} leadingIcon={} > 마이크 / 오디오 설정 열기 )} { setErrorInfo(null) handleStartRecording(true) }} leadingIcon={} > {errorInfo?.type === 'collision' ? '기존 프로세스 초기화 후 즉시 시작' : '강제 초기화 후 다시 시도'} ) // ── 렌더: 목록 뷰 ── if (view === 'list') { return ( {totalSessions > 0 && ( {t('meeting.sessions', { count: totalSessions })} )} {micDeviceName || '기본 마이크'} {sttProvider === 'local' ? 'Local Whisper' : sttProvider.toUpperCase()} } action={ handleStartRecording(false)} disabled={isStarting} trailingIcon={isStarting ? : } > {isStarting ? '회의 준비 중...' : t('meeting.newMeeting')} } /> {loading ? ( {t('common.loading')} ) : sessions.length === 0 ? ( } action={ handleStartRecording(false)} disabled={isStarting}> {isStarting ? ( ) : ( )} {isStarting ? '회의 준비 중...' : t('meeting.newMeeting')} } /> ) : ( {sessions.map((session) => ( handleViewDetail(session.id)} sx={{ cursor: 'pointer', transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)', '&:hover': { transform: 'translateY(-2px)' }, }} > {/* Status Badge Row */} {session.status === 'completed' ? t('meeting.completed') : session.status === 'error' ? t('meeting.error') : session.status === 'processing' ? t('meeting.processing') : t('meeting.recording')} {formatDate(session.startedAt)} {/* Title */} {session.title ?? t('meeting.untitled')} {/* Metadata Footer */} {session.durationMs != null ? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) }) : '—'} {session.memoCount} Memos {/* Processing Progress Bar */} {session.status === 'processing' && progress && progress.sessionId === session.id && ( )} ))} )} {snackbar} ) } // ── 렌더: 실시간 녹음 & 온더플라이 수정 + 문맥 보강 메모 뷰 ── if (view === 'recording') { const isProcessing = stateInfo?.state === 'processing' return ( {/* Top Recording Cockpit */} {formatTime(elapsedMs)} {isProcessing ? t('meeting.processing') : t('meeting.recording')} {!isProcessing && ( {t('meeting.stopRecording')} )} {/* Processing Progress */} {isProcessing && progress && ( {t(`meeting.processingStep.${progress.step}` as Parameters[0])} ({progress.percent}%) )} {/* Main Recording Workspace */} {/* Left: Real-time Interactive Transcript (클릭하여 지난 발화 즉시 수정 가능!) */} {t('meeting.transcript')} (클릭하여 지난 발화 즉시 수정 가능) {segments.length} Segments {segments.map((seg) => ( ))} {/* Right: Live Context Enricher & Quick Tags Memos */} CONTEXT ENRICHER {memos.length} Memos 실시간 작성된 메모는 AI 회의록 생성 시 최우선 문맥으로 반영됩니다. {/* Quick Context Category Chips */} {CONTEXT_TAGS.map((ct) => ( handleTagClick(ct.label)} sx={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 0.5 }} > {ct.icon} {ct.label} ))} {/* Memos List */} {memos.map((memo) => ( {formatTime(memo.timestampMs)} {memo.content} ))} {/* Memo Input */} {!isProcessing && ( setMemoInput(e.target.value)} onKeyDown={handleMemoKeyDown} sx={{ '& .MuiInputBase-root': { fontFamily: d3roFontSans, fontSize: '13px', bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.inner, }, }} /> )} {/* Live Audio Spectrum Meter */} {!isProcessing && ( )} {snackbar} {errorDialog} ) } // ── 렌더: 상세 뷰 ── if (view === 'detail' && detail) { return ( <> { setDetail(null); setView('list') }} /> {errorDialog} ) } return errorDialog }