// 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 = (
)
// ── 렌더: 목록 뷰 ──
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
}