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

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,9 @@
// src/renderer/pages/DashboardPage.tsx
// v2 "Midnight Glass" 대시보드 — 레퍼런스 시안 재현:
// 히어로(웨이브+CTA) / 컬러 링 스탯 4타일 / 백엔드·요금제 / 사용량·실시간 자막 /
// 시스템 상태(웨이브+레벨미터) / 파일 전사 / 최근 히스토리
// v3 "Tactile Liquid & Midnight Glass" Voice Cockpit
// High-End Agency Architecture: DoubleBezel Hero / 4-Tile Bento / Multi-Engine Hub / Telemetry
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box, Button, Typography } from '@mui/material'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box, Button, Typography, Tooltip } from '@mui/material'
import {
Mic,
BookOpen,
@ -16,58 +15,41 @@ import {
Star,
Check,
Cpu,
Sparkles,
Zap,
Activity,
Radio,
ArrowUpRight,
} from 'lucide-react'
import { GradientWave, Led, MetalCard, PhosphorText, PhysicalButton, StatRing, TiltCard } from '@d3ro/ui/components/ds'
import {
GradientWave,
Led,
MetalCard,
PhosphorText,
PhysicalButton,
StatRing,
TiltCard,
DoubleBezelCard,
TactileBadge,
AudioVisualizerBar,
} from '@d3ro/ui/components/ds'
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { formatHotkeyLabel } from '../utils/format-hotkey'
import { FileDropZone } from '../components/FileDropZone'
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
// ── 로컬 프리미티브 ───────────────────────────────────
/** 옅은 헤어라인 필 (레퍼런스의 "TUE" / "로컬 연결" 칩) */
function Pill({
children,
tone = 'neutral',
}: {
children: React.ReactNode
tone?: 'neutral' | 'accent' | 'green'
}): React.ReactElement {
const color =
tone === 'accent' ? d3roPalette.accent.light : tone === 'green' ? d3roPalette.tag.green : d3roPalette.text.secondary
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: d3roRadius.pill,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
bgcolor: d3roPalette.glass.raised,
color,
...typoSx('meta'),
fontFamily: d3roFontSans,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{children}
</Box>
)
}
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
function CardHeader({
title,
right,
icon,
}: {
title: string
right?: React.ReactNode
icon?: React.ReactNode
}): React.ReactElement {
return (
<Box
@ -80,7 +62,10 @@ function CardHeader({
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<PhosphorText variant="heading">{title}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{icon && <Box sx={{ color: d3roPalette.accent.light, display: 'flex' }}>{icon}</Box>}
<PhosphorText variant="heading">{title}</PhosphorText>
</Box>
{right}
</Box>
)
@ -96,22 +81,20 @@ function GradientBar({
alt?: boolean
}): React.ReactElement {
return (
<Box sx={{ height: 5, borderRadius: d3roRadius.pill, bgcolor: d3roPalette.bg.inset, overflow: 'hidden' }}>
<Box sx={{ height: 6, borderRadius: d3roRadius.pill, bgcolor: d3roPalette.bg.inset, overflow: 'hidden' }}>
<Box
sx={{
height: '100%',
width: `${Math.min(100, Math.max(4, ratio * 100))}%`,
backgroundImage: alt ? d3roPalette.gradient.barAlt : d3roPalette.gradient.bar,
borderRadius: d3roRadius.pill,
transition: 'width 0.3s ease',
transition: 'width 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
}}
/>
</Box>
)
}
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
const { t, formatRelativeDate } = useI18n()
const [stats, setStats] = useState<StatsSummary | null>(null)
@ -160,7 +143,9 @@ export function DashboardPage(): React.ReactElement {
useEffect(() => {
loadData()
const interval = setInterval(loadData, 30000)
const unsub = window.electronAPI.app.onDataChanged(() => { loadData() })
const unsub = window.electronAPI.app.onDataChanged(() => {
loadData()
})
const unsubCaption = window.electronAPI.caption.onStateChanged((state) => {
setCaptionState(state)
})
@ -169,12 +154,14 @@ export function DashboardPage(): React.ReactElement {
})
// 오디오 이벤트가 없을 때 서서히 감쇠
audioDecayRef.current = setInterval(() => {
setAudioLevel(prev => prev > 0.01 ? prev * 0.85 : 0)
setAudioLevel((prev) => (prev > 0.01 ? prev * 0.85 : 0))
}, 100)
return () => {
clearInterval(interval)
if (audioDecayRef.current) clearInterval(audioDecayRef.current)
unsub(); unsubCaption(); unsubAudio()
unsub()
unsubCaption()
unsubAudio()
}
}, [loadData])
@ -191,17 +178,24 @@ export function DashboardPage(): React.ReactElement {
return Object.values(groups)
}, [history, formatRelativeDate])
const services = useMemo(() => [
{ name: t('service.sttEngine'), status: t('service.ready'), ok: true },
{ name: t('service.ollamaLlm'), status: ollamaConnected ? t('service.connected') : t('service.offline'), ok: ollamaConnected },
{
name: t('service.premiumLlm'),
status: premiumStatus?.available ? t('service.connected') : t('service.notConfigured'),
ok: premiumStatus?.available ?? false,
},
{ name: t('service.hotkeyHook'), status: t('service.active'), ok: true },
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
], [t, ollamaConnected, premiumStatus])
const services = useMemo(
() => [
{ name: t('service.sttEngine'), status: t('service.ready'), ok: true },
{
name: t('service.ollamaLlm'),
status: ollamaConnected ? t('service.connected') : t('service.offline'),
ok: ollamaConnected,
},
{
name: t('service.premiumLlm'),
status: premiumStatus?.available ? t('service.connected') : t('service.notConfigured'),
ok: premiumStatus?.available ?? false,
},
{ name: t('service.hotkeyHook'), status: t('service.active'), ok: true },
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
],
[t, ollamaConnected, premiumStatus],
)
const allOk = services.every((s) => s.ok || s.name === t('service.premiumLlm'))
const now = new Date()
@ -212,65 +206,139 @@ export function DashboardPage(): React.ReactElement {
}, [])
const statTiles = [
{ ring: 'blue' as const, icon: <Mic size={20} />, label: t('dashboard.recordingTime'), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), sub: t('dashboard.sessionsToday') },
{ ring: 'purple' as const, icon: <BookOpen size={20} />, label: t('dashboard.recognizedWords'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total'), sub: null },
{ ring: 'green' as const, icon: <CalendarDays size={20} />, label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions'), sub: null },
{ ring: 'orange' as const, icon: <Flame size={20} />, label: t('dashboard.streakUse'), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days'), sub: null },
{
ring: 'blue' as const,
icon: <Mic size={20} />,
label: t('dashboard.recordingTime'),
value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0),
unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0),
sub: t('dashboard.sessionsToday'),
},
{
ring: 'accent' as const,
icon: <BookOpen size={20} />,
label: t('dashboard.recognizedWords'),
value: formatNumber(stats?.totalWordCount ?? 0),
unit: t('dashboard.total'),
sub: null,
},
{
ring: 'green' as const,
icon: <CalendarDays size={20} />,
label: t('dashboard.sessionsToday'),
value: `${stats?.todaySessionCount ?? 0}`,
unit: t('dashboard.sessions'),
sub: null,
},
{
ring: 'orange' as const,
icon: <Flame size={20} />,
label: t('dashboard.streakUse'),
value: `${stats?.streakDays ?? 0}`,
unit: t('dashboard.days'),
sub: null,
},
]
return (
<Box sx={{ maxWidth: 980, mx: 'auto', p: 4, pt: 5, pb: 8 }}>
{/* ── 1. 히어로 카드 ─────────────────────────── */}
<TiltCard sx={{ p: 3.5 }}>
{/* 상단 라인: 라벨 / 날짜 / 시스템 칩 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse size={7} />
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontFamily: d3roFontSans }}>
{t('dashboard.sessionsToday')}
</PhosphorText>
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
{/* ── 1. 히어로 더블-베젤 코크핏 ─────────────────────────── */}
<DoubleBezelCard innerPadding={3.5}>
{/* 상단 라인: 시스템 텔레메트리 칩 & 실시간 상태 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<TactileBadge ledColor={allOk ? 'green' : 'amber'} ledPulse={allOk} tone="accent">
{allOk ? t('dashboard.systemOk').toUpperCase() : t('dashboard.systemIssue').toUpperCase()}
</TactileBadge>
<TactileBadge mono tone="mono">
100% LOCAL PRIVACY
</TactileBadge>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{now.toLocaleDateString()}
</PhosphorText>
<Pill>{weekday}</Pill>
<Pill tone={allOk ? 'green' : 'neutral'}>
<Led color={allOk ? 'green' : 'orange'} size={6} />
{allOk ? t('dashboard.systemOk') : t('dashboard.systemIssue')}
</Pill>
<TactileBadge mono>{weekday}</TactileBadge>
</Box>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 3, alignItems: 'start' }}>
{/* 좌: 헤드라인 + 힌트 + 키 설정 */}
{/* 메인 히어로 그리드 */}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr auto auto' }, gap: 3.5, alignItems: 'start' }}>
{/* 좌: 메인 타이틀 + 단축키 힌트 */}
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="hero" sx={{ mb: 1 }}>
<PhosphorText variant="hero" sx={{ mb: 1, letterSpacing: '-0.02em', lineHeight: 1.15 }}>
{t('dashboard.heroIdle')}
</PhosphorText>
<PhosphorText variant="body" sx={{ mb: 2 }}>
{dictationBinding
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() })
: t('dashboard.hotkeyNotSet')}
</PhosphorText>
<PhysicalButton onClick={openSettings} sx={{ height: 34, px: 1.5, gap: 0.75 }}>
<KeyRound size={13} />
{t('dashboard.keySetup')}
</PhysicalButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 2.5, flexWrap: 'wrap' }}>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
{dictationBinding ? t('dashboard.pressToRecord', { key: '' }) : t('dashboard.hotkeyNotSet')}
</PhosphorText>
{dictationBinding && (
<Box
sx={{
px: 1.25,
py: 0.35,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.inset,
fontFamily: d3roFontMono,
fontSize: '12px',
fontWeight: 700,
color: d3roPalette.accent.light,
letterSpacing: '0.04em',
}}
>
{formatHotkeyLabel(dictationBinding).toUpperCase()}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhysicalButton
tone="glass"
onClick={openSettings}
trailingIcon={<ArrowUpRight size={13} />}
sx={{ height: 38, px: 2 }}
>
<KeyRound size={14} style={{ marginRight: 4 }} />
{t('dashboard.keySetup')}
</PhysicalButton>
</Box>
</Box>
{/* 중: 수치 2종 */}
<Box sx={{ display: 'flex', gap: 4, pt: 0.5 }}>
{[
{ label: t('dashboard.words'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total') },
{ label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: '' },
{
label: t('dashboard.words'),
value: formatNumber(stats?.totalWordCount ?? 0),
unit: t('dashboard.total'),
},
{
label: t('dashboard.sessionsToday'),
value: `${stats?.todaySessionCount ?? 0}`,
unit: t('dashboard.sessions'),
},
].map((item) => (
<Box key={item.label} sx={{ textAlign: 'left' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, mb: 0.5, display: 'block' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, mb: 0.75, display: 'block' }}>
{item.label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '34px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '34px',
fontWeight: 700,
lineHeight: 1,
color: d3roPalette.text.primary,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.02em',
}}
>
{item.value}
</Typography>
{item.unit && (
@ -284,43 +352,63 @@ export function DashboardPage(): React.ReactElement {
</Box>
{/* 우: CTA 버튼 스택 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 150 }}>
<Button
variant="contained"
color="primary"
startIcon={<BarChart3 size={15} />}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 160 }}>
<PhysicalButton
tone="accent"
onClick={() => statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
sx={{ py: 1.25 }}
trailingIcon={<ArrowUpRight size={13} />}
>
<BarChart3 size={15} />
{t('dashboard.viewStats')}
</Button>
<PhysicalButton onClick={openSettings} sx={{ gap: 0.75 }}>
</PhysicalButton>
<PhysicalButton tone="glass" onClick={openSettings}>
<Settings2 size={14} />
{t('dashboard.systemSettings')}
</PhysicalButton>
</Box>
</Box>
{/* 하단: 그라디언트 웨이브 */}
<Box sx={{ mt: 2.5, height: 56 }}>
{/* 하단: 실시간 그라디언트 웨이브폼 */}
<Box sx={{ mt: 3.5, height: 64 }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={0.3} barWidth={3} barGap={3} />
</Box>
</TiltCard>
</DoubleBezelCard>
{/* ── 2. 컬러 링 스탯 4타일 ───────────────────── */}
<Box ref={statsRef} sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, mt: 2.5 }}>
{/* ── 2. 컬러 링 스탯 4타일 (Bento Grid) ───────────────────── */}
<Box
ref={statsRef}
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
gap: 2,
mt: 3,
}}
>
{statTiles.map((tile) => (
<TiltCard key={tile.label} maxTilt={6} sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<StatRing color={tile.ring} size={52}>
<StatRing color={tile.ring} size={54} thickness={2.5}>
{tile.icon}
</StatRing>
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5, whiteSpace: 'nowrap' }}>
<PhosphorText
variant="small"
sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5, whiteSpace: 'nowrap' }}
>
{tile.label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '26px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '26px',
fontWeight: 700,
lineHeight: 1,
color: d3roPalette.text.primary,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.01em',
}}
>
{tile.value}
</Typography>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
@ -334,19 +422,28 @@ export function DashboardPage(): React.ReactElement {
</Box>
{/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mt: 3 }}>
{/* 백엔드 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<MetalCard sx={{ p: 2.75 }}>
<CardHeader
title={t('dashboard.currentBackend')}
icon={<Cpu size={16} />}
right={
<Pill tone="accent">
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.localConnected')}
</Pill>
<TactileBadge
ledColor={premiumStatus?.backend === 'premium' || ollamaConnected ? 'green' : 'red'}
ledPulse={premiumStatus?.backend === 'premium' || ollamaConnected}
tone={premiumStatus?.backend === 'premium' || ollamaConnected ? 'success' : 'error'}
>
{premiumStatus?.backend === 'premium'
? t('dashboard.backendPremium')
: ollamaConnected
? t('dashboard.localConnected')
: t('service.offline')}
</TactileBadge>
}
/>
<MetalCard inset sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<StatRing color="accent" size={46} thickness={2}>
<StatRing color={ollamaConnected || premiumStatus?.backend === 'premium' ? 'accent' : 'blue'} size={48} thickness={2}>
<Cpu size={18} />
</StatRing>
<Box sx={{ minWidth: 0, flex: 1 }}>
@ -357,8 +454,22 @@ export function DashboardPage(): React.ReactElement {
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.modelLabel')}: {llmModel ?? '—'}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
cursor: 'pointer',
'&:hover': { opacity: 0.8 },
}}
onClick={() => {
window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide'))
}}
>
<PhosphorText
variant="small"
sx={{ color: ollamaConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')}
</PhosphorText>
<Led color={ollamaConnected ? 'green' : 'red'} size={7} />
@ -369,24 +480,44 @@ export function DashboardPage(): React.ReactElement {
</MetalCard>
{/* 요금제 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader title={t('license.currentTier')} />
<MetalCard sx={{ p: 2.75 }}>
<CardHeader title={t('license.currentTier')} icon={<Star size={16} />} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<StatRing color="blue" size={40} thickness={2}>
<Star size={15} />
<StatRing color="blue" size={44} thickness={2}>
<Star size={16} />
</StatRing>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '30px', fontWeight: 800, color: d3roPalette.text.primary, lineHeight: 1 }}>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '28px',
fontWeight: 800,
color: d3roPalette.text.primary,
lineHeight: 1,
letterSpacing: '-0.02em',
}}
>
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
</Typography>
</Box>
{licenseTier === 'free' && <Pill>{t('dashboard.planFree')}</Pill>}
{licenseTier === 'free' && <TactileBadge tone="mono">{t('dashboard.planFree')}</TactileBadge>}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, pl: 2, borderLeft: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
pl: 2.5,
borderLeft: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
{[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => (
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ color: d3roPalette.tag.green, display: 'flex' }}><Check size={14} /></Box>
<Box sx={{ color: d3roPalette.tag.green, display: 'flex' }}>
<Check size={14} />
</Box>
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.secondary }}>
{feat}
</PhosphorText>
@ -398,24 +529,33 @@ export function DashboardPage(): React.ReactElement {
</Box>
{/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mt: 3 }}>
{/* 사용량 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader title={t('license.usageToday')} />
<MetalCard sx={{ p: 2.75 }}>
<CardHeader title={t('license.usageToday')} icon={<Activity size={16} />} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{usageQuotas.length === 0 && (
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>—</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>
—
</PhosphorText>
)}
{usageQuotas.map((q, idx) => (
<MetalCard inset key={q.feature} sx={{ p: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<StatRing color={idx % 2 === 0 ? 'blue' : 'purple'} size={32} thickness={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.25 }}>
<StatRing color={idx % 2 === 0 ? 'accent' : 'blue'} size={32} thickness={2}>
{idx % 2 === 0 ? <Mic size={13} /> : <Cpu size={13} />}
</StatRing>
<PhosphorText variant="compact" sx={{ flex: 1 }}>
{t(`license.feature.${q.feature}`)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: q.remaining === 0 && q.limit !== -1 ? d3roPalette.tag.red : d3roPalette.accent.light }}>
<PhosphorText
variant="small"
sx={{
color:
q.remaining === 0 && q.limit !== -1 ? d3roPalette.tag.red : d3roPalette.accent.light,
fontFamily: d3roFontMono,
}}
>
{q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`}
</PhosphorText>
</Box>
@ -429,30 +569,26 @@ export function DashboardPage(): React.ReactElement {
</MetalCard>
{/* 실시간 자막 카드 */}
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column' }}>
<MetalCard sx={{ p: 2.75, display: 'flex', flexDirection: 'column' }}>
<CardHeader
title={t('dashboard.caption')}
icon={<Radio size={16} />}
right={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led
color={captionState === 'active' ? 'red' : 'off'}
size={7}
pulse={captionState === 'active'}
/>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>
{captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
</PhosphorText>
</Box>
<TactileBadge
ledColor={captionState === 'active' ? 'red' : 'off'}
ledPulse={captionState === 'active'}
tone={captionState === 'active' ? 'error' : 'default'}
>
{captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
</TactileBadge>
}
/>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2 }}>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2.5 }}>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
{t('dashboard.captionPlaceholder')}
</PhosphorText>
<Button
variant="contained"
color="primary"
startIcon={<Mic size={15} />}
<PhysicalButton
tone={captionState === 'active' ? 'danger' : 'accent'}
onClick={async () => {
if (captionState === 'active') {
await window.electronAPI.caption.stop()
@ -462,27 +598,48 @@ export function DashboardPage(): React.ReactElement {
}}
sx={{ alignSelf: 'center', px: 4 }}
>
<Mic size={15} style={{ marginRight: 4 }} />
{captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')}
</Button>
</PhysicalButton>
</Box>
</MetalCard>
</Box>
{/* ── 5. 시스템 상태 ──────────────────────────── */}
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
<CardHeader title={t('dashboard.systemStatus')} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 3, alignItems: 'center' }}>
{/* ── 5. 시스템 상태 및 오디오 스펙트럼 ──────────────────── */}
<MetalCard sx={{ p: 2.75, mt: 3 }}>
<CardHeader title={t('dashboard.systemStatus')} icon={<Zap size={16} />} />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'auto 1fr' }, gap: 3.5, alignItems: 'center' }}>
{/* 좌: 서비스 상태 리스트 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 220 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 240 }}>
{services.map((svc) => (
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
key={svc.name}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
cursor: !svc.ok && svc.name === t('service.ollamaLlm') ? 'pointer' : 'default',
borderRadius: d3roRadius.xs,
px: 0.5,
py: 0.25,
'&:hover': !svc.ok ? { bgcolor: d3roPalette.glass.raised } : {},
}}
onClick={() => {
if (!svc.ok && svc.name === t('service.ollamaLlm')) {
window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide'))
}
}}
>
<Led color={svc.ok ? 'green' : 'red'} size={7} />
<PhosphorText variant="small" sx={{ flex: 1, color: d3roPalette.text.secondary }}>
<PhosphorText variant="compact" sx={{ flex: 1, color: d3roPalette.text.primary }}>
{svc.name}
</PhosphorText>
<PhosphorText
variant="meta"
sx={{ color: svc.ok ? d3roPalette.tag.green : d3roPalette.tag.red }}
variant="small"
sx={{
color: svc.ok ? d3roPalette.text.secondary : d3roPalette.tag.red,
fontFamily: d3roFontMono,
}}
>
{svc.status}
</PhosphorText>
@ -490,105 +647,76 @@ export function DashboardPage(): React.ReactElement {
))}
</Box>
{/* 중: 웨이브 + 레벨 미터 */}
<Box sx={{ minWidth: 0 }}>
<Box sx={{ height: 64, mb: 1.25 }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={0.35} barWidth={2.5} barGap={2.5} glow={6} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="meta" sx={{ whiteSpace: 'nowrap' }}>
{t('dashboard.inputLevel')}
</PhosphorText>
<Box sx={{ display: 'flex', gap: '3px', flex: 1 }}>
{Array.from({ length: 24 }, (_, i) => (
<Box
key={i}
sx={{
flex: 1,
height: 8,
borderRadius: '2px',
bgcolor: i < Math.round(audioLevel * 24) ? d3roPalette.tag.green : d3roPalette.bg.inset,
transition: 'background-color 0.1s linear',
}}
/>
))}
</Box>
<PhosphorText variant="meta" sx={{ color: d3roPalette.tag.green, minWidth: 34, textAlign: 'right' }}>
{Math.round(audioLevel * 100)}%
</PhosphorText>
</Box>
</Box>
{/* 우: 백엔드 요약 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 180 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
{/* 우: 실시간 오디오 레벨 & 비주얼라이저 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('service.ollamaLlm')}
{t('service.audioInput')} Telemetry
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: ollamaConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaConnected ? t('service.ready') : t('service.offline')}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.modelLabel')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, fontFamily: d3roFontMono }}>
{llmModel ?? '—'}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.statusLabel')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: allOk ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{allOk ? 'CONNECTED' : 'CHECK'}
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{Math.round(audioLevel * 100)}% RMS
</PhosphorText>
</Box>
<AudioVisualizerBar audioLevel={audioLevel} bars={32} height={52} />
</Box>
</Box>
</MetalCard>
{/* ── 6. 파일 전사 ────────────────────────────── */}
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
<CardHeader title={t('fileTranscription.title')} />
{/* ── 6. 파일 전사 드롭존 ──────────────────────────── */}
<Box sx={{ mt: 3 }}>
<FileDropZone />
</MetalCard>
</Box>
{/* ── 7. 최근 히스토리 ──────────────────────── */}
{/* ── 7. 최근 히스토리 목록 ───────────────────────── */}
<Box sx={{ mt: 4 }}>
<PhosphorText variant="heading" sx={{ mb: 2, display: 'block' }}>
{t('dashboard.recentTranscriptions')}
</PhosphorText>
<CardHeader
title={t('history.title')}
right={
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{t('history.entries', { count: history.length })}
</PhosphorText>
}
/>
{history.length === 0 ? (
<EmptyStateCard message={t('dashboard.noHistory')} />
<EmptyStateCard message={t('history.noHistory')} />
) : (
groupedHistory.map((group) => (
<Box key={group.label} sx={{ mb: 3 }}>
{/* 날짜 구분자 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('dashboard.entries', { count: group.entries.length })}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{groupedHistory.map((group) => (
<Box key={group.label}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('history.count', { count: group.entries.length })}
</PhosphorText>
</Box>
{/* 히스토리 항목 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{group.entries.map((entry) => (
<HistoryEntryCard
key={entry.id}
entry={entry}
onCopy={(text) => navigator.clipboard.writeText(text)}
/>
))}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
gap: 1.5,
}}
>
{group.entries.map((entry) => (
<HistoryEntryCard
key={entry.id}
entry={entry}
onCopy={(text) => navigator.clipboard.writeText(text)}
onDelete={(id) => {
window.electronAPI.history.delete({ id })
loadData()
}}
showTags
/>
))}
</Box>
</Box>
</Box>
))
))}
</Box>
)}
</Box>
</Box>

View file

@ -1,12 +1,12 @@
// src/renderer/pages/DictionaryPage.tsx
// 인스트루먼트 미학: MetalCard + PhosphorText + 타이포 토큰
// Precision Vocabulary & Phonetic Dictionary Editor
import { useState, useEffect, useCallback } from 'react'
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'
import { Plus, Trash2, Pencil } from 'lucide-react'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Tooltip } from '@mui/material'
import { Plus, Trash2, Pencil, BookA, Sparkles } from 'lucide-react'
import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds'
import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
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 { DictionaryEntry, DictionaryPage as DictPageData } from '@d3ro/core/types'
@ -21,19 +21,23 @@ export function DictionaryPage(): React.ReactElement {
const [editId, setEditId] = useState<string | null>(null)
const [formWord, setFormWord] = useState('')
const [formPronunciation, setFormPronunciation] = useState('')
const [saving, setSaving] = useState(false)
const isSavingRef = useRef(false)
const isDeletingRef = useRef(false)
const loadData = useCallback(async () => {
setLoading(true)
const result = search.trim()
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
const result = await window.electronAPI.dictionary.getAll({ search: search || undefined })
if (result.success) setData(result.data)
setLoading(false)
}, [search])
useEffect(() => { loadData() }, [loadData])
useEffect(() => {
loadData()
}, [loadData])
const openAdd = () => {
if (dialogOpen) return
setEditId(null)
setFormWord('')
setFormPronunciation('')
@ -41,6 +45,7 @@ export function DictionaryPage(): React.ReactElement {
}
const openEdit = (entry: DictionaryEntry) => {
if (dialogOpen) return
setEditId(entry.id)
setFormWord(entry.word)
setFormPronunciation(entry.pronunciation ?? '')
@ -48,76 +53,142 @@ export function DictionaryPage(): React.ReactElement {
}
const handleSave = async () => {
if (!formWord.trim()) return
if (editId) {
await window.electronAPI.dictionary.update({
id: editId,
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
} else {
await window.electronAPI.dictionary.add({
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
if (!formWord.trim() || saving || isSavingRef.current) return
isSavingRef.current = true
setSaving(true)
try {
if (editId) {
await window.electronAPI.dictionary.update({
id: editId,
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
} else {
await window.electronAPI.dictionary.add({
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
}
setDialogOpen(false)
loadData()
} finally {
setSaving(false)
isSavingRef.current = false
}
}
const handleDelete = async (id: string) => {
if (isDeletingRef.current) return
isDeletingRef.current = true
try {
await window.electronAPI.dictionary.delete({ id })
loadData()
} finally {
isDeletingRef.current = false
}
setDialogOpen(false)
loadData()
}
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
title={t('dictionary.title').toUpperCase()}
count={t('dictionary.words', { count: data?.total ?? 0 }).toUpperCase()}
title={t('dictionary.title')}
count={t('dictionary.words', { count: data?.total ?? 0 })}
action={
<Button variant="contained" startIcon={<Plus size={16} />} onClick={openAdd} size="small">
{t('dictionary.add').toUpperCase()}
</Button>
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}>
{t('dictionary.add')}
</PhysicalButton>
}
/>
<SearchInput
placeholder={t('dictionary.search').toUpperCase()}
placeholder={t('dictionary.search')}
value={search}
onChange={setSearch}
/>
{loading ? (
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">{t('common.loading')}</PhosphorText>
</Box>
) : !data || data.entries.length === 0 ? (
<EmptyStateCard message={search ? t('dictionary.noResults').toUpperCase() : t('dictionary.noWords').toUpperCase()} />
<EmptyStateCard
message={search ? t('dictionary.noResults') : t('dictionary.noWords')}
icon={<BookA />}
action={
<PhysicalButton tone="accent" onClick={openAdd}>
<Plus size={15} style={{ marginRight: 4 }} />
{t('dictionary.add')}
</PhysicalButton>
}
/>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 1.75 }}>
{data.entries.map((entry: DictionaryEntry) => (
<MetalCard key={entry.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
<Box sx={{ fontSize: d3roTypo.body.size, fontWeight: d3roTypo.heading.weight, color: d3roPalette.text.primary }}>{entry.word}</Box>
<MetalCard key={entry.id} sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 0.75, flexWrap: 'wrap' }}>
<Box
sx={{
fontSize: d3roTypo.heading.size,
fontWeight: 700,
fontFamily: d3roFontSans,
color: d3roPalette.text.primary,
}}
>
{entry.word}
</Box>
{entry.pronunciation && (
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
<TactileBadge mono tone="accent">
[{entry.pronunciation}]
</TactileBadge>
)}
</Box>
<Box sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
color: d3roPalette.text.inactive,
mt: 0.5,
letterSpacing: d3roTypo.label.spacing,
}}>
{entry.category.toUpperCase()} · {t('dictionary.used', { count: entry.usageCount })}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 1 }}>
<TactileBadge mono tone="mono">
{entry.category.toUpperCase()}
</TactileBadge>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dictionary.used', { count: entry.usageCount })}
</PhosphorText>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => openEdit(entry)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}>
<Pencil size={16} />
</IconButton>
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<Trash2 size={16} />
</IconButton>
<Tooltip title={t('common.edit')}>
<IconButton
size="small"
onClick={() => openEdit(entry)}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong },
}}
>
<Pencil size={15} />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
onClick={() => {
window.electronAPI.dictionary.delete({ id: entry.id })
loadData()
}}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Trash2 size={15} />
</IconButton>
</Tooltip>
</Box>
</Box>
</MetalCard>
@ -125,15 +196,50 @@ export function DictionaryPage(): React.ReactElement {
</Box>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? t('dictionary.editTitle') : t('dictionary.addTitle')}</DialogTitle>
<DialogContent>
<TextField label={t('dictionary.word')} value={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
<TextField label={t('dictionary.pronunciation')} value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
{/* Add / Edit Dialog */}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
maxWidth="xs"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.card,
borderRadius: d3roRadius.doubleBezelOuter,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.dialog,
p: 1,
},
}}
>
<DialogTitle sx={{ fontWeight: 700, fontFamily: d3roFontSans, color: d3roPalette.text.primary, pt: 2, px: 2.5 }}>
{editId ? t('dictionary.editTitle') : t('dictionary.addTitle')}
</DialogTitle>
<DialogContent sx={{ px: 2.5 }}>
<TextField
label={t('dictionary.word')}
value={formWord}
onChange={(e) => setFormWord(e.target.value)}
fullWidth
autoFocus
sx={{ mt: 1.5 }}
/>
<TextField
label={t('dictionary.pronunciation')}
value={formPronunciation}
onChange={(e) => setFormPronunciation(e.target.value)}
fullWidth
sx={{ mt: 2.5 }}
helperText="e.g. 디쓰리오"
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}>{t('common.save')}</Button>
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
{t('common.cancel')}
</PhysicalButton>
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formWord.trim() || saving}>
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
</PhysicalButton>
</DialogActions>
</Dialog>
</Box>

View file

@ -1,12 +1,11 @@
// src/renderer/pages/HistoryPage.tsx
// 인스트루먼트 미학: MetalCard + PhosphorText + Led + 날짜 그룹핑
// Phase 10: 태그 필터링 + 태그 관리 통합
// High-End Agency Dictation History & Memory Timeline
import { useState, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box, Chip, IconButton, Tooltip } from '@mui/material'
import { Download } from 'lucide-react'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { Download, Trash2, Tag as TagIcon, Clock, Filter } from 'lucide-react'
import { PhosphorText, TactileBadge, DoubleBezelCard, PhysicalButton } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo, d3roFontMono, d3roFontSans, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { getDateKey } from '../utils/formatters'
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
@ -45,7 +44,10 @@ export function HistoryPage(): React.ReactElement {
useEffect(() => {
loadData()
loadTags()
const unsub = window.electronAPI.app.onDataChanged(() => { loadData(); loadTags() })
const unsub = window.electronAPI.app.onDataChanged(() => {
loadData()
loadTags()
})
return unsub
}, [loadData, loadTags])
@ -66,126 +68,164 @@ export function HistoryPage(): React.ReactElement {
navigator.clipboard.writeText(text)
}, [])
const handleDelete = useCallback((id: string) => {
window.electronAPI.history.delete({ id })
loadData()
}, [loadData])
const isExportingRef = useRef(false)
const isDeletingAllRef = useRef(false)
const isDeletingSingleRef = useRef(false)
const handleDelete = useCallback(
async (id: string) => {
if (isDeletingSingleRef.current) return
isDeletingSingleRef.current = true
try {
await window.electronAPI.history.delete({ id })
loadData()
} finally {
isDeletingSingleRef.current = false
}
},
[loadData],
)
const handleTagClick = useCallback((tag: string) => {
setActiveTag(prev => prev === tag ? null : tag)
setActiveTag((prev) => (prev === tag ? null : tag))
setSearch('')
}, [])
const handleExport = useCallback(async () => {
await window.electronAPI.memo.export({
format: 'markdown' as const,
tag: activeTag ?? undefined,
})
if (isExportingRef.current) return
isExportingRef.current = true
try {
await window.electronAPI.memo.export({
format: 'markdown' as const,
tag: activeTag ?? undefined,
})
} finally {
isExportingRef.current = false
}
}, [activeTag])
const handleDeleteAll = useCallback(async () => {
if (isDeletingAllRef.current) return
isDeletingAllRef.current = true
try {
await window.electronAPI.history.deleteAll()
loadData()
} finally {
isDeletingAllRef.current = false
}
}, [loadData])
return (
<Box sx={{ mx: 'auto', p: 4, overflow: 'hidden' }}>
{/* 페이지 헤더 -- 각인 스타일 */}
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
{/* Page Header */}
<PageHeader
title={t('history.title').toUpperCase()}
title={t('history.title')}
count={t('history.entries', { count: data?.total ?? 0 })}
action={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<Tooltip title={t('memo.export')} arrow>
<PhysicalButton size="small" onClick={handleExport} sx={{ height: 34, px: 1.5 }}>
<Download size={14} style={{ marginRight: 4 }} />
{t('memo.export')}
</PhysicalButton>
</Tooltip>
<Tooltip title={t('common.clearAll') || '모두 지우기'} arrow>
<IconButton
size="small"
onClick={handleExport}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}
data-testid="clear-all-history-button"
onClick={handleDeleteAll}
sx={{
color: d3roPalette.text.inactive,
p: 0.75,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Download size={18} />
<Trash2 size={16} />
</IconButton>
</Tooltip>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{t('history.entries', { count: data?.total ?? 0 }).toUpperCase()}
</PhosphorText>
</Box>
}
/>
{/* 태그 필터 바 */}
{allTags.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{allTags.map(tc => (
<Chip
key={tc.tag}
label={`#${tc.tag} (${tc.count})`}
size="small"
variant={activeTag === tc.tag ? 'filled' : 'outlined'}
onClick={() => handleTagClick(tc.tag)}
sx={{
height: 22,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
borderRadius: d3roRadius.small,
borderColor: activeTag === tc.tag ? d3roPalette.accent.main : d3roPalette.border.subtle,
bgcolor: activeTag === tc.tag ? d3roPalette.accent.main : 'transparent',
color: activeTag === tc.tag ? d3roPalette.bg.card : d3roPalette.text.secondary,
'&:hover': {
bgcolor: activeTag === tc.tag ? d3roPalette.accent.main : d3roPalette.bg.cardHover,
},
}}
/>
))}
{activeTag && (
<Chip
label={t('memo.clearFilter')}
size="small"
onClick={() => setActiveTag(null)}
sx={{
height: 22,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
borderRadius: d3roRadius.small,
color: d3roPalette.text.muted,
'&:hover': { color: d3roPalette.tag.red },
}}
/>
)}
</Box>
)}
{/* Search Input Bar */}
{!activeTag && (
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('history.search').toUpperCase()}
placeholder={t('history.search')}
/>
)}
{/* Tag Filters Bar */}
{allTags.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mr: 0.5, color: d3roPalette.text.dimLabel }}>
<Filter size={12} />
<PhosphorText variant="meta">{t('memo.tags') || 'TAGS'}:</PhosphorText>
</Box>
{allTags.map((tc) => {
const isSelected = activeTag === tc.tag
return (
<TactileBadge
key={tc.tag}
mono
tone={isSelected ? 'accent' : 'default'}
onClick={() => handleTagClick(tc.tag)}
sx={{ cursor: 'pointer' }}
>
#{tc.tag} <Box component="span" sx={{ opacity: 0.6 }}>({tc.count})</Box>
</TactileBadge>
)
})}
{activeTag && (
<TactileBadge
tone="error"
onClick={() => setActiveTag(null)}
sx={{ cursor: 'pointer' }}
>
{t('memo.clearFilter')} ✕
</TactileBadge>
)}
</Box>
)}
{loading ? (
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">{t('common.loading')}</PhosphorText>
</Box>
) : !data || data.entries.length === 0 ? (
<EmptyStateCard
message={search ? t('history.noResults').toUpperCase() : t('history.noHistory').toUpperCase()}
message={search ? t('history.noResults') : t('history.noHistory')}
icon={<Clock />}
/>
) : (
groupedEntries.map((group) => (
<Box key={group.label} sx={{ mb: 3 }}>
{/* 날짜 구분자 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<Box key={group.label} sx={{ mb: 3.5 }}>
{/* Date Group Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.75 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('history.count', { count: group.entries.length })}
</PhosphorText>
</Box>
{/* 항목 — 반응형 그리드 */}
<Box sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
},
gap: 1.5,
}}>
{/* Entries Grid */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
md: 'repeat(2, 1fr)',
},
gap: 1.75,
}}
>
{group.entries.map((entry: HistoryEntry) => (
<HistoryEntryCard
key={entry.id}

View file

@ -1,14 +1,13 @@
// src/renderer/pages/KnowledgeBasePage.tsx
// Phase 13.2: 로컬 RAG Knowledge Base UI
// 문서 관리 + 질문 입력 + 답변 표시
// Local RAG Knowledge Base & Semantic Memory Intelligence
import { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
import { Plus, Trash2, RefreshCw, Search, Send, FileText } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds'
import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles, CheckCircle2 } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
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 { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types'
@ -30,7 +29,9 @@ export function KnowledgeBasePage(): React.ReactElement {
setLoading(false)
}, [])
useEffect(() => { loadDocuments() }, [loadDocuments])
useEffect(() => {
loadDocuments()
}, [loadDocuments])
useEffect(() => {
const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => {
@ -40,27 +41,41 @@ export function KnowledgeBasePage(): React.ReactElement {
setIndexProgress(null)
loadDocuments()
})
return () => { unsubProgress(); unsubComplete() }
}, [loadDocuments])
const handleAddDocument = useCallback(async () => {
setAdding(true)
setAddError(null)
const resp = await window.electronAPI.rag.addDocument()
setAdding(false)
if (resp.success) {
loadDocuments()
} else {
if (!resp.error.message.includes('cancelled')) {
setAddError(resp.error.message)
}
return () => {
unsubProgress()
unsubComplete()
}
}, [loadDocuments])
const handleRemoveDocument = useCallback(async (docId: string) => {
await window.electronAPI.rag.removeDocument({ documentId: docId })
loadDocuments()
}, [loadDocuments])
const isAddingRef = useRef(false)
const handleAddDocument = useCallback(async () => {
if (isAddingRef.current || adding) return
isAddingRef.current = true
setAdding(true)
setAddError(null)
try {
const resp = await window.electronAPI.rag.addDocument()
if (resp.success) {
loadDocuments()
} else {
if (!resp.error.message.includes('cancelled') && !resp.error.message.includes('already active')) {
setAddError(resp.error.message)
}
}
} finally {
setAdding(false)
isAddingRef.current = false
}
}, [adding, loadDocuments])
const handleRemoveDocument = useCallback(
async (docId: string) => {
await window.electronAPI.rag.removeDocument({ documentId: docId })
loadDocuments()
},
[loadDocuments],
)
const handleReindex = useCallback(async (docId: string) => {
await window.electronAPI.rag.reindex(docId)
@ -77,53 +92,65 @@ export function KnowledgeBasePage(): React.ReactElement {
setQuerying(false)
}, [query])
const handleQueryKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuery()
}
}, [handleQuery])
const handleQueryKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuery()
}
},
[handleQuery],
)
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
title={t('rag.title').toUpperCase()}
title={t('rag.title')}
count={`${documents.length} ${t('rag.documents')}`}
action={
<PhysicalButton size="small" onClick={handleAddDocument} disabled={adding}>
<Plus size={14} style={{ marginRight: 4 }} /> {adding ? t('rag.adding') : t('rag.addDocument')}
<PhysicalButton
tone="accent"
size="small"
onClick={handleAddDocument}
disabled={adding}
trailingIcon={<Plus size={14} />}
>
{adding ? t('rag.adding') : t('rag.addDocument')}
</PhysicalButton>
}
/>
{/* 인덱싱 진행률 */}
{/* Indexing Progress Card */}
{indexProgress && (
<MetalCard sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{t('rag.indexing')}: {indexProgress.fileName}
<MetalCard sx={{ mb: 2.5, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{t('rag.indexing')}: {indexProgress.fileName}
</PhosphorText>
</Box>
<PhosphorText variant="meta">
{indexProgress.currentChunk} / {indexProgress.totalChunks} Chunks ({indexProgress.percent}%)
</PhosphorText>
</Box>
<LinearProgress
variant="determinate"
value={indexProgress.percent}
sx={{
height: 4,
borderRadius: 2,
height: 6,
borderRadius: d3roRadius.pill,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{indexProgress.currentChunk}/{indexProgress.totalChunks}
</PhosphorText>
</MetalCard>
)}
{/* 에러 표시 */}
{/* Error Card */}
{addError && (
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.tag.red}` }}>
<MetalCard sx={{ mb: 2.5, p: 2, border: `1px solid ${d3roPalette.tag.redBg}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="red" />
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red }}>
@ -133,67 +160,10 @@ export function KnowledgeBasePage(): React.ReactElement {
</MetalCard>
)}
{/* 추가 중 인디케이터 */}
{adding && !indexProgress && (
<MetalCard sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">{t('rag.parsing')}</PhosphorText>
</Box>
</MetalCard>
)}
{/* 문서 목록 */}
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('rag.documents').toUpperCase()} ({documents.length})
</PhosphorText>
{documents.length === 0 && !loading ? (
<EmptyStateCard message={t('rag.noDocuments')} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 3 }}>
{documents.map((doc) => (
<MetalCard key={doc.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FileText size={20} style={{ color: d3roPalette.text.inactive }} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="body">{doc.fileName}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<PhosphorText variant="dim">
{doc.fileType.toUpperCase()} · {doc.chunkCount} {t('rag.chunks')} ·
</PhosphorText>
<Led color={doc.indexed ? 'green' : 'amber'} size={6} />
<PhosphorText variant="dim">
{doc.indexed ? t('rag.indexed') : t('rag.pending')}
</PhosphorText>
</Box>
</Box>
<Tooltip title={t('rag.reindex')}>
<IconButton size="small" onClick={() => handleReindex(doc.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton size="small" onClick={() => handleRemoveDocument(doc.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<Trash2 size={16} />
</IconButton>
</Tooltip>
</Box>
</MetalCard>
))}
</Box>
)}
{/* 질문 입력 */}
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('rag.askQuestion').toUpperCase()}
</PhosphorText>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Search size={20} style={{ color: d3roPalette.text.inactive }} />
{/* Asking / Semantic Query Bar */}
<DoubleBezelCard innerPadding={2.5} sx={{ mb: 3.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Search size={18} style={{ color: d3roPalette.accent.light }} />
<TextField
value={query}
onChange={(e) => setQuery(e.target.value)}
@ -203,57 +173,228 @@ export function KnowledgeBasePage(): React.ReactElement {
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.inset,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.main },
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.glass.hairline}`,
'& fieldset': { border: 'none' },
'&:hover': { borderColor: d3roPalette.glass.hairlineStrong },
'&.Mui-focused': { borderColor: d3roPalette.accent.main },
},
'& .MuiOutlinedInput-input': { color: d3roPalette.text.primary },
'& .MuiOutlinedInput-input': { color: d3roPalette.text.primary, py: 1.25 },
}}
/>
<IconButton onClick={handleQuery} disabled={!query.trim() || querying}
sx={{ color: query.trim() ? d3roPalette.accent.main : d3roPalette.text.inactive }}>
<Send size={20} />
</IconButton>
<PhysicalButton
tone="accent"
onClick={handleQuery}
disabled={!query.trim() || querying}
sx={{ height: 40, px: 2.5 }}
>
<Send size={15} style={{ marginRight: 4 }} />
{querying ? t('common.loading') : 'Search'}
</PhysicalButton>
</Box>
</MetalCard>
{/* 답변 */}
{querying && (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Led color="amber" pulse />
<PhosphorText variant="dim" sx={{ ml: 1 }}>{t('rag.searching')}</PhosphorText>
</Box>
)}
{result && (
<Box sx={{ mt: 2 }}>
<ScreenPanel sx={{ p: 3 }}>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8 }}>
{result.answer}
{/* Quick Suggestion Prompt Chips */}
{documents.length > 0 && !result && !querying && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel, mr: 0.5 }}>
PROMPTS:
</PhosphorText>
</ScreenPanel>
{[
'이 문서들의 핵심 내용 3줄 요약해줘',
'주요 일정 및 마일스톤 정리해줘',
'보안 및 개인정보 관련 정책은?',
].map((sample) => (
<TactileBadge
key={sample}
onClick={() => {
setQuery(sample)
setTimeout(handleQuery, 50)
}}
sx={{ cursor: 'pointer' }}
>
{sample}
</TactileBadge>
))}
</Box>
)}
{result.results.length > 0 && (
<Box sx={{ mt: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 1, display: 'block' }}>
{t('rag.sources').toUpperCase()}
{/* Answer Result Display */}
{querying && (
<Box sx={{ mt: 3, textAlign: 'center', py: 2 }}>
<Led color="amber" pulse size={8} />
<PhosphorText variant="dim" sx={{ ml: 1.5 }}>
{t('rag.searching')}
</PhosphorText>
</Box>
)}
{result && (
<Box sx={{ mt: 3, pt: 2.5, borderTop: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Sparkles size={16} style={{ color: d3roPalette.accent.light }} />
<PhosphorText variant="heading" sx={{ color: d3roPalette.accent.light }}>
Semantic Synthesis
</PhosphorText>
{result.results.slice(0, 3).map((r, i) => (
<MetalCard key={i} sx={{ mb: 1 }}>
<PhosphorText variant="dim">
[{i + 1}] {r.fileName} ({Math.round(r.similarity * 100)}%)
</PhosphorText>
<PhosphorText variant="compact" sx={{ mt: 0.5, opacity: 0.7 }}>
{r.content.slice(0, 150)}...
</PhosphorText>
</MetalCard>
))}
</Box>
)}
<Box
sx={{
p: 2.5,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.inset,
fontFamily: d3roFontSans,
fontSize: '14px',
lineHeight: 1.7,
color: d3roPalette.text.primary,
whiteSpace: 'pre-wrap',
mb: 2,
}}
>
{result.answer}
</Box>
{/* Sources Attribution */}
{result.results.length > 0 && (
<Box>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel, mb: 1.25, display: 'block' }}>
{t('rag.sources').toUpperCase()} ({result.results.length})
</PhosphorText>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{result.results.slice(0, 3).map((r, i) => (
<MetalCard key={i} sx={{ p: 1.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.75 }}>
<PhosphorText variant="compact" sx={{ fontWeight: 600, color: d3roPalette.text.primary }}>
[{i + 1}] {r.fileName}
</PhosphorText>
<TactileBadge mono tone="accent">
{Math.round(r.similarity * 100)}%
</TactileBadge>
</Box>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, opacity: 0.85, lineHeight: 1.5 }}>
{r.content.slice(0, 120)}...
</PhosphorText>
</MetalCard>
))}
</Box>
</Box>
)}
</Box>
)}
</DoubleBezelCard>
{/* Document Library Section */}
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('rag.documents').toUpperCase()} ({documents.length})
</PhosphorText>
</Box>
)}
{documents.length === 0 && !loading ? (
<EmptyStateCard
message={t('rag.noDocuments')}
icon={<Database />}
action={
<PhysicalButton tone="accent" onClick={handleAddDocument}>
<Plus size={15} style={{ marginRight: 4 }} />
{t('rag.addDocument')}
</PhysicalButton>
}
/>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 1.75 }}>
{documents.map((doc) => (
<MetalCard key={doc.id} sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
flexShrink: 0,
}}
>
<FileText size={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<PhosphorText
variant="heading"
sx={{
mb: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{doc.fileName}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap', mt: 1 }}>
<TactileBadge mono tone="mono">
{doc.fileType.toUpperCase()}
</TactileBadge>
<TactileBadge mono tone="default">
{doc.chunkCount} {t('rag.chunks')}
</TactileBadge>
<TactileBadge
ledColor={doc.indexed ? 'green' : 'amber'}
tone={doc.indexed ? 'success' : 'warning'}
>
{doc.indexed ? t('rag.indexed') : t('rag.pending')}
</TactileBadge>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={t('rag.reindex')}>
<IconButton
size="small"
onClick={() => handleReindex(doc.id)}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong },
}}
>
<RefreshCw size={14} />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
onClick={() => handleRemoveDocument(doc.id)}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
</Box>
</Box>
)
}

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
}

View file

@ -1,16 +1,24 @@
// src/renderer/pages/VoiceConversationPage.tsx
// Phase 13.1: 음성 대화 모드 UI
// STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼.
// Real-Time Duplex Conversational Voice Assistant Canvas
import { useState, useEffect, useCallback, useRef } from 'react'
import { Alert, Box, IconButton, Snackbar, TextField, Tooltip } from '@mui/material'
import { Mic, Square, Send, Trash2, XCircle } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Alert, Box, IconButton, Snackbar, TextField, Tooltip, Typography } from '@mui/material'
import { Mic, Square, Send, Trash2, XCircle, Bot, User, Sparkles, Volume2 } from 'lucide-react'
import {
MetalCard,
PhosphorText,
Led,
PhysicalButton,
ScreenPanel,
DoubleBezelCard,
TactileBadge,
AudioVisualizerBar,
} from '@d3ro/ui/components/ds'
import { PageHeader } from '../components/shared'
import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel'
import { useRealtimeConversation } from '../hooks/useRealtimeConversation'
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 {
ConversationState,
@ -34,14 +42,12 @@ export function VoiceConversationPage(): React.ReactElement {
const realtime = useRealtimeConversation()
const isRealtime = backend === 'realtime'
// 표시용 상태 — 백엔드에 따라 로컬 파이프라인(IPC) vs Realtime 훅
const state = isRealtime ? realtime.conversationState : localState
const messages = isRealtime ? realtime.messages : localMessages
const isActive = isRealtime ? realtime.isActive : localActive
const streamingText = isRealtime ? realtime.streamingText : localStreamingText
const streamingMsgId = isRealtime ? realtime.streamingMsgId : localStreamingMsgId
// 설정에서 대화 백엔드 로드
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success && r.data.conversationBackend === 'realtime') {
@ -50,7 +56,6 @@ export function VoiceConversationPage(): React.ReactElement {
})
}, [])
// Realtime 에러 → 배너
useEffect(() => {
if (realtime.error) {
setErrorBanner({ phase: 'llm', message: realtime.error })
@ -61,7 +66,6 @@ export function VoiceConversationPage(): React.ReactElement {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
// IPC 이벤트 구독 (로컬 파이프라인)
useEffect(() => {
const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => {
setLocalState(info.state)
@ -74,23 +78,26 @@ export function VoiceConversationPage(): React.ReactElement {
setLocalStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => {
setLocalStreamingMsgId(data.messageId)
setLocalStreamingText(data.accumulated)
setTimeout(scrollToBottom, 50)
})
const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta(
(data: ConversationAssistantDelta) => {
setLocalStreamingMsgId(data.messageId)
setLocalStreamingText(data.accumulated)
setTimeout(scrollToBottom, 50)
},
)
const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => {
setLocalMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }])
setLocalMessages((prev) => [
...prev,
{ id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() },
])
setLocalStreamingText('')
setLocalStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubError = window.electronAPI.voiceConversation.onError((err: ConversationError) => {
// 서비스는 자동 복구(listening 재진입)까지 처리. 여기선 배너만 띄운다.
setErrorBanner(err)
})
// 초기 상태 로드
window.electronAPI.voiceConversation.getState().then((r) => {
if (r.success) {
setLocalState(r.data.state)
@ -108,23 +115,31 @@ export function VoiceConversationPage(): React.ReactElement {
}
}, [scrollToBottom])
// Realtime 메시지 갱신 시 스크롤
useEffect(() => {
if (isRealtime) setTimeout(scrollToBottom, 50)
}, [isRealtime, realtime.messages, realtime.streamingText, scrollToBottom])
const isSendingRef = useRef(false)
const isStartingSessionRef = useRef(false)
const isClearingRef = useRef(false)
const handleStartSession = useCallback(async () => {
if (isRealtime) {
const ok = await realtime.start()
if (!ok) {
// Realtime 연결 실패 → 로컬 파이프라인 자동 fallback
setBackend('local')
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
await window.electronAPI.voiceConversation.startSession()
if (isStartingSessionRef.current) return
isStartingSessionRef.current = true
try {
if (isRealtime) {
const ok = await realtime.start()
if (!ok) {
setBackend('local')
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
await window.electronAPI.voiceConversation.startSession()
}
return
}
return
await window.electronAPI.voiceConversation.startSession()
} finally {
isStartingSessionRef.current = false
}
await window.electronAPI.voiceConversation.startSession()
}, [isRealtime, realtime, t])
const handleStopSession = useCallback(async () => {
@ -136,41 +151,52 @@ export function VoiceConversationPage(): React.ReactElement {
}, [isRealtime, realtime])
const handleFinishListening = useCallback(async () => {
if (isRealtime) return // server VAD가 자동 처리
if (isRealtime) return
await window.electronAPI.voiceConversation.finishListening()
}, [isRealtime])
const handleSendText = useCallback(async () => {
if (!textInput.trim()) return
if (!textInput.trim() || isSendingRef.current) return
isSendingRef.current = true
const text = textInput.trim()
setTextInput('')
if (isRealtime) {
if (!realtime.isActive) {
const ok = await realtime.start()
if (!ok) {
setBackend('local')
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
await window.electronAPI.voiceConversation.startSession()
await window.electronAPI.voiceConversation.sendMessage({ text })
return
try {
if (isRealtime) {
if (!realtime.isActive) {
const ok = await realtime.start()
if (!ok) {
setBackend('local')
setErrorBanner({ phase: 'llm', message: t('conversation.realtime.fallback') })
await window.electronAPI.voiceConversation.startSession()
await window.electronAPI.voiceConversation.sendMessage({ text })
return
}
}
realtime.sendText(text)
return
}
realtime.sendText(text)
return
if (!isActive) {
await window.electronAPI.voiceConversation.startSession()
}
await window.electronAPI.voiceConversation.sendMessage({ text })
} finally {
isSendingRef.current = false
}
if (!isActive) {
await window.electronAPI.voiceConversation.startSession()
}
await window.electronAPI.voiceConversation.sendMessage({ text })
}, [textInput, isActive, isRealtime, realtime, t])
const handleClearHistory = useCallback(async () => {
if (isRealtime) {
realtime.clearMessages()
return
if (isClearingRef.current) return
isClearingRef.current = true
try {
if (isRealtime) {
realtime.clearMessages()
return
}
await window.electronAPI.voiceConversation.clearHistory()
setLocalMessages([])
} finally {
isClearingRef.current = false
}
await window.electronAPI.voiceConversation.clearHistory()
setLocalMessages([])
}, [isRealtime, realtime])
const handleCancelResponse = useCallback(async () => {
@ -181,13 +207,16 @@ export function VoiceConversationPage(): React.ReactElement {
await window.electronAPI.voiceConversation.cancelResponse()
}, [isRealtime, realtime])
const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendText()
}
}, [handleSendText])
const handleTextKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendText()
}
},
[handleSendText],
)
const stateLabel = {
idle: t('conversation.idle'),
@ -201,14 +230,16 @@ export function VoiceConversationPage(): React.ReactElement {
? t('conversation.realtime.connecting')
: stateLabel[state]
const formatErrorMessage = useCallback((err: ConversationError): string => {
// Bug 13의 전형적 STT 메시지는 i18n 키로 매핑 — 그 외는 서비스 원문 + phase label.
if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) {
return t('conversation.error.noSpeech')
}
const phaseLabel = t(`conversation.error.phase.${err.phase}`)
return `${phaseLabel}: ${err.message}`
}, [t])
const formatErrorMessage = useCallback(
(err: ConversationError): string => {
if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) {
return t('conversation.error.noSpeech')
}
const phaseLabel = t(`conversation.error.phase.${err.phase}`)
return `${phaseLabel}: ${err.message}`
},
[t],
)
const stateLedColor = {
idle: 'amber' as const,
@ -218,33 +249,47 @@ export function VoiceConversationPage(): React.ReactElement {
}
return (
<Box sx={{ maxWidth: 800, mx: 'auto', p: 4, pb: 8, display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
maxWidth: 960,
mx: 'auto',
p: { xs: 2.5, md: 4 },
pb: 8,
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<PageHeader
title={t('conversation.title').toUpperCase()}
title={t('conversation.title')}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1.25, alignItems: 'center' }}>
{isRealtime && (
<PhosphorText
variant="micro"
sx={{
color: d3roPalette.accent.main,
border: `1px solid ${d3roPalette.accent.main}`,
borderRadius: '3px', // P4: 토큰에 없는 3px 보존
px: 0.5,
lineHeight: 1.6,
}}
>
<TactileBadge tone="accent">
{t('conversation.realtime.badge')}
</PhosphorText>
</TactileBadge>
)}
<Led color={stateLedColor[state]} pulse={state === 'listening' || state === 'thinking'} size={8} />
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
<TactileBadge
ledColor={stateLedColor[state]}
ledPulse={state === 'listening' || state === 'thinking' || state === 'speaking'}
tone={state === 'speaking' ? 'success' : state === 'listening' ? 'error' : 'default'}
>
{displayStateLabel.toUpperCase()}
</PhosphorText>
</TactileBadge>
{messages.length > 0 && (
<Tooltip title={t('conversation.clearHistory')}>
<IconButton size="small" onClick={handleClearHistory} sx={{ color: d3roPalette.text.inactive }}>
<Trash2 size={18} />
<IconButton
size="small"
onClick={handleClearHistory}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Trash2 size={15} />
</IconButton>
</Tooltip>
)}
@ -252,17 +297,9 @@ export function VoiceConversationPage(): React.ReactElement {
}
/>
{/* 메시지 영역 — 로컬 파이프라인의 listening만 몰입 패널 (Realtime은 항상 채팅 뷰) */}
{/* Messages Canvas */}
{!isRealtime && state === 'listening' ? (
<Box
sx={{
flex: 1,
mt: 2,
mb: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ flex: 1, my: 2, display: 'flex', flexDirection: 'column' }}>
<VoiceRecordingPanel />
</Box>
) : (
@ -270,58 +307,127 @@ export function VoiceConversationPage(): React.ReactElement {
sx={{
flex: 1,
overflow: 'auto',
mt: 2,
mb: 2,
my: 2,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
gap: 2,
pr: 0.5,
}}
>
{messages.length === 0 && !streamingText && state !== 'thinking' && (
<Box sx={{ textAlign: 'center', mt: 8 }}>
<PhosphorText variant="heading" sx={{ color: d3roPalette.text.inactive, mb: 1 }}>
<Box sx={{ textAlign: 'center', my: 'auto', py: 8 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 2.5,
color: d3roPalette.accent.light,
boxShadow: `0 0 32px ${d3roPalette.accent.dim}`,
}}
>
<Bot size={32} />
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 700, color: d3roPalette.text.primary, mb: 1 }}>
{t('conversation.empty')}
</PhosphorText>
<PhosphorText variant="dim">
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', color: d3roPalette.text.secondary, maxWidth: 400, mx: 'auto' }}>
{t('conversation.emptyHint')}
</PhosphorText>
</Typography>
</Box>
)}
{messages.map((msg) => (
<Box
key={msg.id}
sx={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<MetalCard
{messages.map((msg) => {
const isUser = msg.role === 'user'
return (
<Box
key={msg.id}
sx={{
maxWidth: '75%',
...(msg.role === 'user' && {
bgcolor: d3roPalette.accent.main,
'& *': { color: `${d3roPalette.bg.chassis} !important` },
}),
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
alignItems: 'flex-start',
gap: 1.5,
}}
>
<PhosphorText
variant="compact"
{!isUser && (
<Box
sx={{
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
flexShrink: 0,
mt: 0.5,
}}
>
<Bot size={16} />
</Box>
)}
<Box
sx={{
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
maxWidth: '80%',
p: 2.25,
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
bgcolor: isUser ? d3roPalette.accent.main : d3roPalette.glass.surface,
backdropFilter: `blur(${d3roPalette.glass.blur})`,
border: `1px solid ${isUser ? 'transparent' : d3roPalette.glass.hairlineStrong}`,
boxShadow: isUser ? d3roShadow.glowAccent : d3roShadow.card,
}}
>
{msg.content}
</PhosphorText>
</MetalCard>
</Box>
))}
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '14px',
color: isUser ? d3roPalette.text.inverse : d3roPalette.text.primary,
lineHeight: 1.65,
whiteSpace: 'pre-wrap',
}}
>
{msg.content}
</Typography>
</Box>
</Box>
)
})}
{/* thinking 중: typing indicator (아직 스트리밍 시작 전) */}
{/* Thinking Typing Indicator */}
{state === 'thinking' && !streamingText && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<MetalCard sx={{ maxWidth: '75%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
}}
>
<Bot size={16} />
</Box>
<Box
sx={{
p: 2,
borderRadius: '16px 16px 16px 4px',
bgcolor: d3roPalette.glass.surface,
border: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<Box
sx={{
display: 'flex',
@ -346,21 +452,55 @@ export function VoiceConversationPage(): React.ReactElement {
<Box component="span" />
<Box component="span" />
</Box>
</MetalCard>
</Box>
</Box>
)}
{/* 스트리밍 중인 어시스턴트 메시지 (thinking 말미 or speaking) */}
{/* Streaming Text Display */}
{streamingText && streamingMsgId && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<MetalCard sx={{ maxWidth: '75%' }}>
<PhosphorText variant="compact" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
flexShrink: 0,
mt: 0.5,
}}
>
<Bot size={16} />
</Box>
<Box
sx={{
maxWidth: '80%',
p: 2.25,
borderRadius: '16px 16px 16px 4px',
bgcolor: d3roPalette.glass.surface,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.card,
}}
>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '14px',
color: d3roPalette.text.primary,
lineHeight: 1.65,
whiteSpace: 'pre-wrap',
}}
>
{streamingText}
<Box component="span" sx={{ animation: 'blink 1s infinite', color: d3roPalette.accent.main }}>
{'▌'}
<Box component="span" sx={{ animation: 'led-pulse 1s infinite', color: d3roPalette.accent.main, ml: 0.5 }}>
{'▋'}
</Box>
</PhosphorText>
</MetalCard>
</Typography>
</Box>
</Box>
)}
@ -368,33 +508,34 @@ export function VoiceConversationPage(): React.ReactElement {
</Box>
)}
{/* 하단 컨트롤 바 */}
<MetalCard>
{/* Bottom Interactive Control Cockpit */}
<DoubleBezelCard innerPadding={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{/* 녹음 버튼 */}
{/* Main Voice Button */}
{!isActive ? (
<PhysicalButton onClick={handleStartSession} sx={{ minWidth: 48, px: 2 }}>
<PhysicalButton tone="accent" onClick={handleStartSession} sx={{ minWidth: 44, height: 44, px: 2 }}>
<Mic size={20} />
</PhysicalButton>
) : state === 'listening' ? (
<PhysicalButton
tone="danger"
selected
onClick={isRealtime ? handleStopSession : handleFinishListening}
sx={{ minWidth: 48, px: 2 }}
sx={{ minWidth: 44, height: 44, px: 2 }}
>
<Square size={20} />
</PhysicalButton>
) : state === 'thinking' || state === 'speaking' ? (
<PhysicalButton onClick={handleCancelResponse} sx={{ minWidth: 48, px: 2 }}>
<PhysicalButton tone="glass" onClick={handleCancelResponse} sx={{ minWidth: 44, height: 44, px: 2 }}>
<XCircle size={20} />
</PhysicalButton>
) : (
<PhysicalButton onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
<PhysicalButton tone="accent" onClick={handleFinishListening} sx={{ minWidth: 44, height: 44, px: 2 }}>
<Mic size={20} />
</PhysicalButton>
)}
{/* 텍스트 입력 — 로컬 listening 시 숨김 (Realtime은 항상 표시), thinking/speaking 시 disabled */}
{/* Text Input Bar */}
{(isRealtime || state !== 'listening') && (
<TextField
value={state === 'thinking' || state === 'speaking' ? '' : textInput}
@ -410,57 +551,52 @@ export function VoiceConversationPage(): React.ReactElement {
disabled={state === 'thinking' || state === 'speaking'}
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.inset,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.main },
'&.Mui-focused fieldset': { borderColor: d3roPalette.accent.main },
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.glass.hairline}`,
'& fieldset': { border: 'none' },
'&:hover': { borderColor: d3roPalette.glass.hairlineStrong },
'&.Mui-focused': { borderColor: d3roPalette.accent.main },
},
'& .MuiOutlinedInput-input': {
color: d3roPalette.text.primary,
py: 1.25,
},
}}
/>
)}
{/* 전송 버튼 — 로컬 listening 시 숨김 */}
{/* Send Button */}
{(isRealtime || state !== 'listening') && (
<IconButton
<PhysicalButton
tone="accent"
onClick={handleSendText}
disabled={!textInput.trim() || state === 'thinking' || state === 'speaking'}
sx={{
color: textInput.trim() && state !== 'thinking' && state !== 'speaking'
? d3roPalette.accent.main
: d3roPalette.text.inactive,
}}
sx={{ height: 44, px: 2.25 }}
>
<Send size={20} />
</IconButton>
<Send size={18} />
</PhysicalButton>
)}
{/* 세션 종료 */}
{/* End Session Button */}
{isActive && (
<PhysicalButton onClick={handleStopSession} sx={{ minWidth: 48, px: 1 }}>
<PhysicalButton tone="glass" onClick={handleStopSession} sx={{ height: 44, px: 1.5 }}>
<PhosphorText variant="micro">{t('conversation.end')}</PhosphorText>
</PhysicalButton>
)}
</Box>
</MetalCard>
</DoubleBezelCard>
{/* 에러 배너 — Bug 13 "No speech detected" 등 사용자 피드백 */}
{/* Error Notification */}
<Snackbar
open={errorBanner !== null}
autoHideDuration={4000}
onClose={() => setErrorBanner(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="warning"
variant="filled"
onClose={() => setErrorBanner(null)}
sx={{ width: '100%' }}
>
<Alert severity="warning" variant="filled" onClose={() => setErrorBanner(null)} sx={{ width: '100%' }}>
{errorBanner ? formatErrorMessage(errorBanner) : ''}
</Alert>
</Snackbar>