d3ro-voice/apps/desktop/src/renderer/pages/MeetingModePage.tsx
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

881 lines
32 KiB
TypeScript

// 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: <CheckCircle2 size={12} />, color: d3roPalette.tag.green },
{ label: '#할일', icon: <ListTodo size={12} />, color: d3roPalette.accent.light },
{ label: '#안건', icon: <Tag size={12} />, color: d3roPalette.tag.orange },
{ label: '#아이디어', icon: <Lightbulb size={12} />, color: d3roPalette.tag.purple },
{ label: '#이슈', icon: <AlertTriangle size={12} />, color: d3roPalette.tag.red },
]
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] = useState(1)
const pageSize = 20
// 사전 점검 및 기동 상태
const [isStarting, setIsStarting] = useState(false)
const [errorInfo, setErrorInfo] = useState<MeetingErrorInfo | null>(null)
const [sttProvider, setSttProvider] = useState<string>('local')
const [, setSttEngineState] = useState<string>('ready')
const [micDeviceName, setMicDeviceName] = useState<string>('')
// 녹음 뷰 상태
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 [audioLevel, setAudioLevel] = useState(0)
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 [snackbarMsg, setSnackbarMsg] = useState<string | null>(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 = (
<Snackbar
open={snackbarMsg !== null}
autoHideDuration={4000}
onClose={() => setSnackbarMsg(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setSnackbarMsg(null)} sx={{ width: '100%' }}>
{snackbarMsg}
</Alert>
</Snackbar>
)
const errorDialog = (
<Dialog
open={errorInfo !== null}
onClose={() => 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,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pb: 1 }}>
<AlertCircle size={22} style={{ color: d3roPalette.tag.red }} />
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 500, fontSize: '17px', color: d3roPalette.text.primary }}>
{errorInfo?.title}
</Typography>
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<Box
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<TactileBadge tone="error" mono>
{errorInfo?.code ? `ERR: ${errorInfo.code}` : 'ERROR'}
</TactileBadge>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.disabled, fontFamily: d3roFontMono }}>
Meeting Intelligence Diagnostics
</Typography>
</Box>
<Typography sx={{ fontSize: '13px', color: d3roPalette.text.secondary, fontFamily: d3roFontMono, wordBreak: 'break-all', mb: 1.5 }}>
{errorInfo?.message}
</Typography>
<Divider sx={{ my: 1, borderColor: d3roPalette.border.subtle }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.2, alignItems: 'flex-start' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 500, color: d3roPalette.tag.orange, minWidth: 65 }}>
</Typography>
<Typography sx={{ fontSize: '12.5px', color: d3roPalette.text.primary, lineHeight: 1.4 }}>
{errorInfo?.reason}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.2, alignItems: 'flex-start' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 500, color: d3roPalette.tag.green, minWidth: 65 }}>
</Typography>
<Typography sx={{ fontSize: '12.5px', color: d3roPalette.text.primary, lineHeight: 1.4 }}>
{errorInfo?.suggestion}
</Typography>
</Box>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2, pt: 1, display: 'flex', gap: 1.5, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Button
variant="outlined"
onClick={() => setErrorInfo(null)}
sx={{
color: d3roPalette.text.secondary,
borderColor: d3roPalette.border.default,
borderRadius: d3roRadius.small,
fontSize: '12px',
}}
>
</Button>
{errorInfo?.type === 'stt' && (
<PhysicalButton
tone="default"
size="small"
onClick={() => {
setErrorInfo(null)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } }))
}}
leadingIcon={<Settings size={13} />}
>
STT /
</PhysicalButton>
)}
{errorInfo?.type === 'audio' && (
<PhysicalButton
tone="default"
size="small"
onClick={() => {
setErrorInfo(null)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } }))
}}
leadingIcon={<Mic size={13} />}
>
/
</PhysicalButton>
)}
<PhysicalButton
tone="accent"
size="small"
onClick={() => {
setErrorInfo(null)
handleStartRecording(true)
}}
leadingIcon={<RefreshCw size={13} />}
>
{errorInfo?.type === 'collision'
? '기존 프로세스 초기화 후 즉시 시작'
: '강제 초기화 후 다시 시도'}
</PhysicalButton>
</DialogActions>
</Dialog>
)
// ── 렌더: 목록 뷰 ──
if (view === 'list') {
return (
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
title={t('meeting.title')}
count={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{totalSessions > 0 && (
<TactileBadge tone="mono" mono size="small">
{t('meeting.sessions', { count: totalSessions })}
</TactileBadge>
)}
<TactileBadge tone="default" size="small">
<Mic size={10} style={{ marginRight: 3, color: d3roPalette.tag.green }} />
{micDeviceName || '기본 마이크'}
</TactileBadge>
<TactileBadge tone="mono" size="small">
<Cpu size={10} style={{ marginRight: 3 }} />
{sttProvider === 'local' ? 'Local Whisper' : sttProvider.toUpperCase()}
</TactileBadge>
</Box>
}
action={
<PhysicalButton
tone="accent"
size="small"
onClick={() => handleStartRecording(false)}
disabled={isStarting}
trailingIcon={isStarting ? <CircularProgress size={13} sx={{ color: 'inherit' }} /> : <Plus size={14} />}
>
{isStarting ? '회의 준비 중...' : t('meeting.newMeeting')}
</PhysicalButton>
}
/>
{loading ? (
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">{t('common.loading')}</PhosphorText>
</Box>
) : sessions.length === 0 ? (
<EmptyStateCard
message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`}
icon={<Users />}
action={
<PhysicalButton tone="accent" onClick={() => handleStartRecording(false)} disabled={isStarting}>
{isStarting ? (
<CircularProgress size={14} sx={{ color: 'inherit', mr: 1 }} />
) : (
<Plus size={15} style={{ marginRight: 4 }} />
)}
{isStarting ? '회의 준비 중...' : t('meeting.newMeeting')}
</PhysicalButton>
}
/>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
lg: 'repeat(3, 1fr)',
},
gap: 2,
}}
>
{sessions.map((session) => (
<Box
key={session.id}
onClick={() => handleViewDetail(session.id)}
sx={{
cursor: 'pointer',
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
'&:hover': { transform: 'translateY(-2px)' },
}}
>
<MetalCard sx={{ p: 2.75, height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Status Badge Row */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<TactileBadge
ledColor={session.status === 'completed' ? 'green' : session.status === 'recording' ? 'red' : 'amber'}
tone={statusTone(session.status)}
>
{session.status === 'completed'
? t('meeting.completed')
: session.status === 'error'
? t('meeting.error')
: session.status === 'processing'
? t('meeting.processing')
: t('meeting.recording')}
</TactileBadge>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{formatDate(session.startedAt)}
</PhosphorText>
</Box>
{/* Title */}
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '15px',
fontWeight: 500,
color: d3roPalette.text.primary,
lineHeight: 1.35,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
mb: 2,
flex: 1,
}}
>
{session.title ?? t('meeting.untitled')}
</Typography>
{/* Metadata Footer */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
pt: 1.5,
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<TactileBadge mono tone="mono">
<Clock size={11} style={{ marginRight: 2 }} />
{session.durationMs != null
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
: '—'}
</TactileBadge>
<TactileBadge mono tone="default">
<FileText size={11} style={{ marginRight: 2 }} />
{session.memoCount} Memos
</TactileBadge>
</Box>
{/* Processing Progress Bar */}
{session.status === 'processing' && progress && progress.sessionId === session.id && (
<Box sx={{ mt: 1.5 }}>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{
height: 4,
borderRadius: d3roRadius.pill,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
}}
/>
</Box>
)}
</MetalCard>
</Box>
))}
</Box>
)}
{snackbar}
</Box>
)
}
// ── 렌더: 실시간 녹음 & 온더플라이 수정 + 문맥 보강 메모 뷰 ──
if (view === 'recording') {
const isProcessing = stateInfo?.state === 'processing'
return (
<Box sx={{ p: { xs: 2.5, md: 4 }, height: '100%', display: 'flex', flexDirection: 'column', pb: 8 }}>
{/* Top Recording Cockpit */}
<DoubleBezelCard innerPadding={2} sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Circle
size={14}
fill="currentColor"
style={{ color: d3roPalette.tag.red, animation: 'led-pulse 1.2s infinite' }}
/>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '20px',
fontWeight: 500,
color: d3roPalette.text.primary,
fontVariantNumeric: 'tabular-nums',
}}
>
{formatTime(elapsedMs)}
</Typography>
<TactileBadge tone={isProcessing ? 'warning' : 'error'} ledColor={isProcessing ? 'amber' : 'red'}>
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
</TactileBadge>
</Box>
{!isProcessing && (
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording}>
<Square size={14} style={{ marginRight: 4 }} />
{t('meeting.stopRecording')}
</PhysicalButton>
)}
</Box>
</DoubleBezelCard>
{/* Processing Progress */}
{isProcessing && progress && (
<MetalCard sx={{ mb: 2, p: 2 }}>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
<PhosphorText variant="dim" sx={{ mt: 0.75, display: 'block' }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
</PhosphorText>
</MetalCard>
)}
{/* Main Recording Workspace */}
<Box sx={{ flex: 1, display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 380px' }, gap: 2.5, minHeight: 0 }}>
{/* Left: Real-time Interactive Transcript (클릭하여 지난 발화 즉시 수정 가능!) */}
<ScreenPanel sx={{ p: 2.5, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhosphorText variant="heading">{t('meeting.transcript')}</PhosphorText>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
( )
</Typography>
</Box>
<TactileBadge mono tone="mono">
{segments.length} Segments
</TactileBadge>
</Box>
<Box
ref={transcriptRef}
sx={{
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{segments.map((seg) => (
<EditableSegment
key={seg.id}
segmentId={seg.id}
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
text={seg.text}
edited={false}
onEdit={handleLiveEditSegment}
readOnly={isProcessing}
/>
))}
</Box>
</ScreenPanel>
{/* Right: Live Context Enricher & Quick Tags Memos */}
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Sparkles size={16} style={{ color: d3roPalette.accent.light }} />
<PhosphorText variant="heading">CONTEXT ENRICHER</PhosphorText>
</Box>
<TactileBadge mono tone="mono">
{memos.length} Memos
</TactileBadge>
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11.5px', color: d3roPalette.text.secondary, mb: 1.5, lineHeight: 1.4 }}>
AI .
</Typography>
{/* Quick Context Category Chips */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.5, flexWrap: 'wrap' }}>
{CONTEXT_TAGS.map((ct) => (
<TactileBadge
key={ct.label}
onClick={() => handleTagClick(ct.label)}
sx={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 0.5 }}
>
{ct.icon}
{ct.label}
</TactileBadge>
))}
</Box>
{/* Memos List */}
<Box sx={{ flex: 1, overflow: 'auto', mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
{memos.map((memo) => (
<Box
key={memo.id}
sx={{
p: 1.5,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<PhosphorText variant="dim" sx={{ fontSize: '10.5px', fontFamily: d3roFontMono, mb: 0.25, display: 'block' }}>
{formatTime(memo.timestampMs)}
</PhosphorText>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', color: d3roPalette.text.primary, lineHeight: 1.5 }}>
{memo.content}
</Typography>
</Box>
))}
</Box>
{/* Memo Input */}
{!isProcessing && (
<TextField
size="small"
fullWidth
placeholder="[#결정/할일/안건] 핵심 메모 입력 (Enter)..."
value={memoInput}
onChange={(e) => setMemoInput(e.target.value)}
onKeyDown={handleMemoKeyDown}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontSans,
fontSize: '13px',
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.inner,
},
}}
/>
)}
</MetalCard>
</Box>
{/* Live Audio Spectrum Meter */}
{!isProcessing && (
<Box sx={{ mt: 2 }}>
<AudioVisualizerBar audioLevel={audioLevel} bars={36} height={44} />
</Box>
)}
{snackbar}
{errorDialog}
</Box>
)
}
// ── 렌더: 상세 뷰 ──
if (view === 'detail' && detail) {
return (
<>
<MeetingDetailTabs detail={detail} onBack={() => { setDetail(null); setView('list') }} />
{errorDialog}
</>
)
}
return errorDialog
}