Phase 14 구현: 회의 모드 (Meeting Mode)

실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 회의록 + PDF/MD 내보내기.
CaptionService 연동, 동시 사용 충돌 방지, 긴 회의 2-pass 요약 지원.
DB 2테이블, IPC 15채널, UI 3뷰(목록/녹음/상세), 12개 locale i18n.
This commit is contained in:
Yun Chan 2026-04-08 01:16:52 +09:00
parent dd68fc9ccd
commit d0c4f9ee53
38 changed files with 2550 additions and 769 deletions

View file

@ -0,0 +1,570 @@
// src/renderer/pages/MeetingModePage.tsx
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
TextField,
IconButton,
Tooltip,
LinearProgress,
Typography,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import StopIcon from '@mui/icons-material/Stop'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import DeleteIcon from '@mui/icons-material/Delete'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import DescriptionIcon from '@mui/icons-material/Description'
import EditIcon from '@mui/icons-material/Edit'
import CheckIcon from '@mui/icons-material/Check'
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type {
MeetingSessionSummary,
MeetingSessionDetail,
MeetingModeStateInfo,
MeetingProcessingProgress,
CaptionSegment,
MeetingMemo,
} from '@shared/types'
type MeetingView = 'list' | 'recording' | 'detail'
export function MeetingModePage(): React.ReactElement {
const { t } = useI18n()
const [view, setView] = useState<MeetingView>('list')
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
const [totalSessions, setTotalSessions] = useState(0)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const pageSize = 20
// 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([])
const [memos, setMemos] = useState<MeetingMemo[]>([])
const [memoInput, setMemoInput] = useState('')
const [elapsedMs, setElapsedMs] = useState(0)
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
const transcriptRef = useRef<HTMLDivElement>(null)
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const recordingStartRef = useRef<number>(0)
// 상세 뷰 상태
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
// ── 세션 목록 로드 ──
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') {
// 후처리 완료 → 목록으로 이동
if (!progress) {
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(() => {
setProgress(null)
setView('list')
})
return () => {
unsubState()
unsubSegment()
unsubProgress()
unsubCompleted()
unsubError()
}
}, [view, progress])
// ── 경과 시간 타이머 ──
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 () => {
const resp = await window.electronAPI.meetingMode.startRecording()
if (resp.success) {
setSegments([])
setMemos([])
setElapsedMs(0)
setProgress(null)
setView('recording')
}
}, [])
const handleStopRecording = useCallback(async () => {
await window.electronAPI.meetingMode.stopRecording()
}, [])
const handleAddMemo = useCallback(async () => {
if (!memoInput.trim()) return
const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() })
if (resp.success) {
setMemos((prev) => [...prev, resp.data])
setMemoInput('')
}
}, [memoInput])
const handleMemoKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddMemo()
}
},
[handleAddMemo],
)
const handleViewDetail = useCallback(async (sessionId: string) => {
const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
if (resp.success) {
setDetail(resp.data)
setView('detail')
}
}, [])
const handleDelete = useCallback(async () => {
if (!detail) return
await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id })
setDetail(null)
setView('list')
}, [detail])
const handleExportPdf = useCallback(async () => {
if (!detail) return
await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id })
}, [detail])
const handleExportMarkdown = useCallback(async () => {
if (!detail) return
await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id })
}, [detail])
const handleSaveTitle = useCallback(async () => {
if (!detail || !titleDraft.trim()) return
await window.electronAPI.meetingMode.updateTitle({
sessionId: detail.id,
title: titleDraft.trim(),
})
setDetail({ ...detail, title: titleDraft.trim() })
setEditingTitle(false)
}, [detail, titleDraft])
// ── 유틸 ──
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 statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => {
switch (status) {
case 'completed': return 'green'
case 'recording': return 'red'
case 'processing': return 'amber'
case 'error': return 'red'
default: return 'blue'
}
}
// ── 렌더: 목록 뷰 ──
if (view === 'list') {
return (
<Box sx={{ p: 3 }}>
<PageHeader
title={t('meeting.title')}
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
action={
<PhysicalButton size="small" onClick={handleStartRecording}>
<AddIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.newMeeting')}
</PhysicalButton>
}
/>
{loading ? (
<LinearProgress sx={{ mt: 2 }} />
) : sessions.length === 0 ? (
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{sessions.map((session) => (
<MetalCard
key={session.id}
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.85 } }}
onClick={() => handleViewDetail(session.id)}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<PhosphorText variant="label">
{session.title ?? t('meeting.untitled')}
</PhosphorText>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{session.durationMs != null
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
: '—'}
{' · '}
{t('meeting.memos')}: {session.memoCount}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhosphorText variant="dim">{formatDate(session.startedAt)}</PhosphorText>
<Led color={statusColor(session.status)} size={8} />
</Box>
</Box>
{/* 프로세싱 중인 세션 표시 */}
{session.status === 'processing' && progress && progress.sessionId === session.id && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{ height: 4, borderRadius: 2 }}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: 11 }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
</PhosphorText>
</Box>
)}
</MetalCard>
))}
</Box>
)}
</Box>
)
}
// ── 렌더: 녹음 뷰 ──
if (view === 'recording') {
const isProcessing = stateInfo?.state === 'processing'
return (
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 상단 바 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FiberManualRecordIcon sx={{ color: d3roPalette.indicator.red, fontSize: 16, animation: 'pulse 1s infinite' }} />
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
{formatTime(elapsedMs)}
</PhosphorText>
<PhosphorText variant="dim">
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
</PhosphorText>
</Box>
{!isProcessing && (
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
<StopIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.stopRecording')}
</PhysicalButton>
)}
</Box>
{/* 프로세싱 진행률 */}
{isProcessing && progress && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
</PhosphorText>
</Box>
)}
{/* 메인 패널: 전사 + 메모 */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
{/* 좌: 실시간 전사 */}
<ScreenPanel sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.transcript')}
</PhosphorText>
<Box
ref={transcriptRef}
sx={{
flex: 1,
overflow: 'auto',
fontFamily: d3roFontMono,
fontSize: d3roTypo.reading.fontSize,
color: d3roPalette.text.primary,
lineHeight: 1.8,
}}
>
{segments.map((seg) => (
<Box key={seg.id} sx={{ mb: 0.5 }}>
<Typography
component="span"
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
>
[{formatTime(seg.timestamp)}]
</Typography>
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
{seg.text}
</Typography>
</Box>
))}
</Box>
</ScreenPanel>
{/* 우: 메모 입력 */}
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.memos')}
</PhosphorText>
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
{memos.map((memo) => (
<Box
key={memo.id}
sx={{
mb: 0.5,
p: 1,
borderRadius: 1,
bgcolor: d3roPalette.bg.chassis,
}}
>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
{formatTime(memo.timestampMs)}
</PhosphorText>
<PhosphorText variant="reading" sx={{ fontSize: 12 }}>
{memo.content}
</PhosphorText>
</Box>
))}
</Box>
{!isProcessing && (
<TextField
size="small"
fullWidth
placeholder={t('meeting.memoPlaceholder')}
value={memoInput}
onChange={(e) => setMemoInput(e.target.value)}
onKeyDown={handleMemoKeyDown}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
},
}}
/>
)}
</Box>
</Box>
</Box>
)
}
// ── 렌더: 상세 뷰 ──
if (view === 'detail' && detail) {
const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
return (
<Box sx={{ p: 3, overflow: 'auto' }}>
{/* 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<IconButton size="small" onClick={() => { setDetail(null); setView('list') }}>
<ArrowBackIcon sx={{ fontSize: 18 }} />
</IconButton>
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
<TextField
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
sx={{ flex: 1 }}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle}>
<CheckIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
) : (
<>
<PhosphorText variant="label" sx={{ flex: 1 }}>
{detail.title ?? t('meeting.untitled')}
</PhosphorText>
<Tooltip title={t('meeting.editTitle')}>
<IconButton
size="small"
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</>
)}
</Box>
{/* 메타 */}
<PhosphorText variant="dim" sx={{ mb: 3 }}>
{formatDate(detail.startedAt)}
{detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
{' · '}
{t('meeting.duration', { minutes: durationMin })}
</PhosphorText>
{/* 회의록 본문 */}
{detail.minutes && (
<>
{/* 요약 */}
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.summary')}</PhosphorText>
<PhosphorText variant="reading" sx={{ whiteSpace: 'pre-wrap' }}>
{detail.minutes.summary}
</PhosphorText>
</MetalCard>
{/* 결정사항 */}
{detail.minutes.decisions.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.decisions')}</PhosphorText>
{detail.minutes.decisions.map((d, i) => (
<PhosphorText key={i} variant="reading" sx={{ mb: 0.5 }}>
{'• '}{d}
</PhosphorText>
))}
</MetalCard>
)}
{/* 할 일 */}
{detail.minutes.actionItems.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.actionItems')}</PhosphorText>
{detail.minutes.actionItems.map((item, i) => (
<PhosphorText key={i} variant="reading" sx={{ mb: 0.5 }}>
{'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
{item.deadline ? ` (${item.deadline})` : ''}
</PhosphorText>
))}
</MetalCard>
)}
{/* 타임라인 */}
{detail.minutes.timeline.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.timeline')}</PhosphorText>
{detail.minutes.timeline.map((tl, i) => (
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
{tl.time}
</PhosphorText>
<PhosphorText variant="reading" sx={{ flex: 1 }}>
{tl.type === 'memo' ? '📝 ' : ''}{tl.content}
</PhosphorText>
</Box>
))}
</MetalCard>
)}
</>
)}
{/* 에러 메시지 */}
{detail.status === 'error' && detail.errorMessage && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.indicator.red, mb: 1 }}>
{t('meeting.error')}
</PhosphorText>
<PhosphorText variant="reading">{detail.errorMessage}</PhosphorText>
</MetalCard>
)}
{/* 메모 */}
{detail.memos.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.memos')}</PhosphorText>
{detail.memos.map((m) => (
<Box key={m.id} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
{formatTime(m.timestampMs)}
</PhosphorText>
<PhosphorText variant="reading">{m.content}</PhosphorText>
</Box>
))}
</MetalCard>
)}
{/* 액션 버튼 */}
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
<Tooltip title={t('meeting.exportPdf')}>
<PhysicalButton size="small" onClick={handleExportPdf}>
<PictureAsPdfIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.exportPdf')}
</PhysicalButton>
</Tooltip>
<Tooltip title={t('meeting.exportMarkdown')}>
<PhysicalButton size="small" onClick={handleExportMarkdown}>
<DescriptionIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.exportMarkdown')}
</PhysicalButton>
</Tooltip>
<Tooltip title={t('meeting.delete')}>
<PhysicalButton size="small" color="error" onClick={handleDelete}>
<DeleteIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.delete')}
</PhysicalButton>
</Tooltip>
</Box>
</Box>
)
}
return <Box />
}