feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -1,21 +1,61 @@
// src/renderer/pages/MeetingModePage.tsx
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
// High-End Meeting Intelligence Studio: Live Transcribing & On-the-fly Editing + Context Enricher Memos
import { useState, useEffect, useCallback, useRef } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
LinearProgress,
TextField,
Snackbar,
Alert,
Typography,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Divider,
CircularProgress,
} from '@mui/material'
import { Plus, Square, Circle } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds'
import {
Plus,
Square,
Circle,
Users,
Clock,
FileText,
Sparkles,
Mic,
Activity,
Trash2,
Tag,
CheckCircle2,
ListTodo,
Lightbulb,
AlertTriangle,
AlertCircle,
Settings,
RefreshCw,
Cpu,
SlidersHorizontal,
} from 'lucide-react'
import {
MetalCard,
PhosphorText,
Led,
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, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
MeetingSessionSummary,
@ -28,6 +68,23 @@ import type {
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')
@ -37,6 +94,13 @@ export function MeetingModePage(): React.ReactElement {
const [page, setPage] = useState(1)
const pageSize = 20
// 사전 점검 및 기동 상태
const [isStarting, setIsStarting] = useState(false)
const [errorInfo, setErrorInfo] = useState<MeetingErrorInfo | null>(null)
const [sttProvider, setSttProvider] = useState<string>('local')
const [sttEngineState, setSttEngineState] = useState<string>('ready')
const [micDeviceName, setMicDeviceName] = useState<string>('')
// 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([])
@ -53,6 +117,77 @@ export function MeetingModePage(): React.ReactElement {
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)
@ -73,7 +208,6 @@ export function MeetingModePage(): React.ReactElement {
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
setStateInfo(info)
if (info.state === 'idle' && view === 'recording') {
// 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침
setProgress(null)
setView('list')
}
@ -81,7 +215,6 @@ export function MeetingModePage(): React.ReactElement {
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
setSegments((prev) => [...prev, seg])
// 자동 스크롤
setTimeout(() => {
if (transcriptRef.current) {
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight
@ -98,13 +231,15 @@ export function MeetingModePage(): React.ReactElement {
setView('list')
})
const unsubError = window.electronAPI.meetingMode.onError(() => {
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)) // RMS 정규화
setAudioLevel(Math.min(data.level * 10, 1))
})
return () => {
@ -115,7 +250,7 @@ export function MeetingModePage(): React.ReactElement {
unsubError()
unsubAudioLevel()
}
}, [view])
}, [view, parseMeetingError])
// ── 경과 시간 타이머 ──
useEffect(() => {
@ -134,32 +269,73 @@ export function MeetingModePage(): React.ReactElement {
}, [view])
// ── 핸들러 ──
const handleStartRecording = useCallback(async () => {
const resp = await window.electronAPI.meetingMode.startRecording()
if (resp.success) {
setSegments([])
setMemos([])
setElapsedMs(0)
setProgress(null)
setView('recording')
} else {
setSnackbarMsg(t('meeting.startRecordingError'))
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)
}
}, [t])
}, [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()) return
const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() })
if (resp.success) {
setMemos((prev) => [...prev, resp.data])
setMemoInput('')
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
@ -171,18 +347,25 @@ export function MeetingModePage(): React.ReactElement {
[handleAddMemo],
)
const handleViewDetail = useCallback(async (sessionId: string) => {
const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
if (resp.success) {
setDetail(resp.data)
setView('detail')
} else {
setSnackbarMsg(t('meeting.viewDetailError'))
}
}, [t])
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)
@ -195,13 +378,18 @@ export function MeetingModePage(): React.ReactElement {
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' => {
const statusTone = (status: string): 'success' | 'warning' | 'error' | 'accent' => {
switch (status) {
case 'completed': return 'green'
case 'recording': return 'red'
case 'processing': return 'amber'
case 'error': return 'red'
default: return 'blue'
case 'completed':
return 'success'
case 'recording':
return 'error'
case 'processing':
return 'warning'
case 'error':
return 'error'
default:
return 'accent'
}
}
@ -218,25 +406,188 @@ export function MeetingModePage(): React.ReactElement {
</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: 700, 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.md,
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: 700, 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: 700, 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.sm,
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={{ p: 3 }}>
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
title={t('meeting.title')}
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
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 size="small" onClick={handleStartRecording}>
<Plus size={16} style={{ marginRight: 4 }} />
{t('meeting.newMeeting')}
<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 ? (
<LinearProgress sx={{ mt: 2 }} />
<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')}`} />
<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={{
@ -244,10 +595,9 @@ export function MeetingModePage(): React.ReactElement {
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
lg: 'repeat(3, 1fr)',
},
gap: 1.5,
gap: 2,
}}
>
{sessions.map((session) => (
@ -256,74 +606,88 @@ export function MeetingModePage(): React.ReactElement {
onClick={() => handleViewDetail(session.id)}
sx={{
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
'&:hover': { transform: 'translateY(-2px)' },
}}
>
<MetalCard>
{/* 제목 — 여러 줄 허용 */}
<PhosphorText
variant="heading"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
mb: 1.5,
}}
>
{session.title ?? t('meeting.untitled')}
</PhosphorText>
<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>
{/* 정보 그리드 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{formatDate(session.startedAt)}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{/* Title */}
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '15px',
fontWeight: 700,
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) })
: '—'}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{t('meeting.memos')}: {session.memoCount}
</PhosphorText>
</TactileBadge>
<TactileBadge mono tone="default">
<FileText size={11} style={{ marginRight: 2 }} />
{session.memoCount} Memos
</TactileBadge>
</Box>
</Box>
{/* 하단 — 상태 표시 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
<Led color={statusColor(session.status)} size={6} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, textTransform: 'uppercase', letterSpacing: d3roTypo.label.spacing }}>
{session.status === 'completed' ? t('meeting.completed')
: session.status === 'error' ? t('meeting.error')
: session.status === 'processing' ? t('meeting.processing')
: t('meeting.recording')}
</PhosphorText>
</Box>
{/* 프로세싱 진행 바 */}
{session.status === 'processing' && progress && progress.sessionId === session.id && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{
height: 3,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: d3roTypo.nano.size }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
</PhosphorText>
</Box>
)}
</MetalCard>
{/* 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>
@ -333,58 +697,80 @@ export function MeetingModePage(): React.ReactElement {
)
}
// ── 렌더: 녹음 뷰 ──
// ── 렌더: 실시간 녹음 & 온더플라이 수정 + 문맥 보강 메모 뷰 ──
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 }}>
<Circle size={16} fill="currentColor" style={{ color: d3roPalette.tag.red, 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}>
<Square size={16} style={{ marginRight: 4 }} />
{t('meeting.stopRecording')}
</PhysicalButton>
)}
</Box>
<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: 700,
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 && (
<Box sx={{ mb: 2 }}>
<MetalCard sx={{ mb: 2, p: 2 }}>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
<PhosphorText variant="dim" sx={{ mt: 0.75, display: 'block' }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
</PhosphorText>
</Box>
</MetalCard>
)}
{/* 메인 패널: 전사 + 메모 */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
{/* 좌: 실시간 전사 */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ScreenPanel>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.transcript')}
</PhosphorText>
{/* 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',
fontFamily: d3roFontMono,
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.8,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{segments.map((seg) => (
@ -394,83 +780,95 @@ export function MeetingModePage(): React.ReactElement {
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
text={seg.text}
edited={false}
onEdit={() => { /* no-op: readOnly=true */ }}
readOnly
onEdit={handleLiveEditSegment}
readOnly={isProcessing}
/>
))}
</Box>
</ScreenPanel>
</Box>
{/* 우: 메모 입력 */}
<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 }}>
{/* 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={{
mb: 0.5,
p: 1,
borderRadius: 1,
bgcolor: d3roPalette.bg.chassis,
p: 1.5,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
<PhosphorText variant="dim" sx={{ fontSize: '10.5px', fontFamily: d3roFontMono, mb: 0.25, display: 'block' }}>
{formatTime(memo.timestampMs)}
</PhosphorText>
<PhosphorText variant="body" sx={{ fontSize: 12 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', color: d3roPalette.text.primary, lineHeight: 1.5 }}>
{memo.content}
</PhosphorText>
</Typography>
</Box>
))}
</Box>
{/* Memo Input */}
{!isProcessing && (
<TextField
size="small"
fullWidth
placeholder={t('meeting.memoPlaceholder')}
placeholder="[#결정/할일/안건] 핵심 메모 입력 (Enter)..."
value={memoInput}
onChange={(e) => setMemoInput(e.target.value)}
onKeyDown={handleMemoKeyDown}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
fontFamily: d3roFontSans,
fontSize: '13px',
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.inner,
},
}}
/>
)}
</Box>
</MetalCard>
</Box>
{/* 오디오 레벨 미터 */}
{/* Live Audio Spectrum Meter */}
{!isProcessing && (
<Box sx={{ mt: 1.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={audioLevel > 0.02 ? 'green' : 'amber'} size={8} />
<Box sx={{
flex: 1,
height: 6,
borderRadius: 3,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: `${Math.max(audioLevel * 100, 0)}%`,
bgcolor: audioLevel > 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green,
borderRadius: 3,
transition: 'width 0.1s ease-out',
}} />
</Box>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono, minWidth: 30 }}>
{Math.round(audioLevel * 100)}%
</PhosphorText>
<Box sx={{ mt: 2 }}>
<AudioVisualizerBar audioLevel={audioLevel} bars={36} height={44} />
</Box>
)}
{snackbar}
{errorDialog}
</Box>
)
}
@ -478,12 +876,12 @@ export function MeetingModePage(): React.ReactElement {
// ── 렌더: 상세 뷰 ──
if (view === 'detail' && detail) {
return (
<MeetingDetailTabs
detail={detail}
onBack={() => { setDetail(null); setView('list') }}
/>
<>
<MeetingDetailTabs detail={detail} onBack={() => { setDetail(null); setView('list') }} />
{errorDialog}
</>
)
}
return <Box />
return errorDialog
}