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.
881 lines
32 KiB
TypeScript
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
|
|
}
|