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
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:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue