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

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

View file

@ -1,8 +1,7 @@
// src/renderer/components/AppLayout.tsx
// v2 "Midnight Glass": 보더리스 셸 — 와이드 사이드바(그라디언트 액티브 필) +
// 앰비언트 배경 + 오버레이 스크롤(레이아웃 폭 불변) + 커스텀 타이틀바
// v3 "Tactile Liquid & Midnight Glass": Borderless Shell with Floating Island Navigation
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { Alert, Box, Snackbar, Typography } from '@mui/material'
import {
LayoutDashboard,
@ -13,9 +12,12 @@ import {
Library,
Users,
Settings,
Sparkles,
Zap,
Headphones,
} from 'lucide-react'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { Led } from '@d3ro/ui/components/ds'
import { Led, TactileBadge } from '@d3ro/ui/components/ds'
import { DashboardPage } from '../pages/DashboardPage'
import { HistoryPage } from '../pages/HistoryPage'
import { DictionaryPage } from '../pages/DictionaryPage'
@ -26,9 +28,13 @@ import { MeetingModePage } from '../pages/MeetingModePage'
import { SettingsModal } from './SettingsModal'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
import { AdBanner } from './ads/AdBanner'
import { RewardedQuotaModal } from './ads/RewardedQuotaModal'
import { SupportModal } from './support/SupportModal'
import { CheckoutModal } from './payment/CheckoutModal'
import { StatusBar } from './StatusBar'
import { TitleBar } from './TitleBar'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { TranslationKey } from '@d3ro/i18n'
import type { LicenseTier } from '@d3ro/core/types'
@ -55,9 +61,15 @@ const NAV_ITEMS: NavItem[] = [
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
switch (tier) {
case 'free': return 'amber'
case 'pro': return 'green'
case 'pro_plus': return 'green'
case 'free':
return 'amber'
case 'pro':
case 'pro_plus':
case 'team':
case 'enterprise':
return 'green'
default:
return 'amber'
}
}
@ -65,15 +77,16 @@ export function AppLayout(): React.ReactElement {
const { t } = useI18n()
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsInitialTab, setSettingsInitialTab] = useState<number>(0)
const [onboardingOpen, setOnboardingOpen] = useState(false)
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
const [supportModalOpen, setSupportModalOpen] = useState(false)
const [checkoutModalOpen, setCheckoutModalOpen] = useState(false)
const [rewardedModalOpen, setRewardedModalOpen] = useState(false)
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
const [fallbackMsg, setFallbackMsg] = useState<string | null>(null)
// 음성 세션 에러/경고 배너 (모델 미설치, 엔진 실패, LLM 스킵 등)
const [voiceAlert, setVoiceAlert] = useState<{ message: string; severity: 'error' | 'warning' } | null>(null)
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success && !r.data.onboardingCompleted) {
@ -82,7 +95,6 @@ export function AppLayout(): React.ReactElement {
})
}, [])
// License: load tier + subscribe to changes + listen for open-modal events
useEffect(() => {
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setCurrentTier(r.data.tier)
@ -99,12 +111,17 @@ export function AppLayout(): React.ReactElement {
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
// 대시보드 CTA(키 설정/시스템 설정)에서 설정 모달 열기
const handleOpenSettings = () => setSettingsOpen(true)
window.addEventListener('d3ro:open-settings', handleOpenSettings)
const handleOpenSettings = (e?: Event) => {
const customEvent = e as CustomEvent<{ tab?: number }> | undefined
if (customEvent?.detail?.tab !== undefined) {
setSettingsInitialTab(customEvent.detail.tab)
} else {
setSettingsInitialTab(0)
}
setSettingsOpen(true)
}
window.addEventListener('d3ro:open-settings', handleOpenSettings as EventListener)
// 음성 세션 에러/경고 — 단축키 녹음 실패를 메인 UI에서도 명확히 알림.
// 모델 미설치(101)는 온보딩 모달을 함께 연다.
const unsubVoiceError = window.electronAPI.voice.onError((e) => {
const severity = e.severity ?? 'error'
let message = e.message
@ -119,7 +136,6 @@ export function AppLayout(): React.ReactElement {
setVoiceAlert({ message, severity })
})
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
setFallbackMsg(e.reason)
})
@ -142,56 +158,66 @@ export function AppLayout(): React.ReactElement {
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
<TitleBar />
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── 사이드바: 와이드 메뉴 (레퍼런스 패턴) ─── */}
{/* ── Sidebar: Floating Island Aesthetic ─── */}
<Box
sx={{
width: 168,
width: 184,
flexShrink: 0,
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
flexDirection: 'column',
px: 1.5,
pt: 3,
px: 1.75,
pt: 2,
pb: 2,
gap: 0.5,
gap: 0.6,
}}
>
{/* 로고 — 그라디언트 브랜드 텍스트 */}
<Box sx={{ px: 1.5, mb: 3, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={8} />
{/* Brand Logo & Tier Badge */}
<Box sx={{ px: 1.25, mb: 3.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={8} />
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '20px',
fontWeight: 800,
letterSpacing: '-0.02em',
backgroundImage: d3roPalette.gradient.logo,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
lineHeight: 1.1,
}}
>
D3RO
</Typography>
</Box>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.value.size,
fontWeight: 800, // P4: 토큰에 없는 800 보존
letterSpacing: '0.02em', // P4: 토큰에 없는 0.02em 보존
backgroundImage: d3roPalette.gradient.logo,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
lineHeight: 1.1,
fontFamily: d3roFontMono,
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.08em',
color: d3roPalette.text.dimLabel,
textTransform: 'uppercase',
}}
>
D3RO
VOICE
</Typography>
</Box>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
fontWeight: 500, // P4: 토큰에 없는 500 보존
letterSpacing: '0.12em', // P4: 토큰에 없는 0.12em 보존
color: d3roPalette.text.dimLabel,
pl: '16px',
}}
>
Voice
</Typography>
{currentTier !== 'free' && (
<Box sx={{ pl: 2, mt: 0.25 }}>
<TactileBadge mono tone="accent">
{currentTier.toUpperCase()}
</TactileBadge>
</Box>
)}
</Box>
{/* 네비게이션 */}
{/* Navigation Items */}
{NAV_ITEMS.map((item) => {
const isActive = currentRoute === item.route
return (
@ -203,20 +229,23 @@ export function AppLayout(): React.ReactElement {
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.25,
py: 1.15,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: isActive ? d3roPalette.text.primary : d3roPalette.text.inactive,
// 액티브: 글래스 필 + 그라디언트 헤어라인 보더 + 소프트 글로우
border: '1px solid transparent',
background: isActive
? `linear-gradient(${d3roPalette.glass.raised}, ${d3roPalette.glass.raised}) padding-box, ${d3roPalette.gradient.accent} border-box`
: 'transparent',
boxShadow: isActive ? d3roShadow.glowSoft : 'none',
transition: 'color 0.15s ease, background-color 0.15s ease',
transition: 'all 0.18s cubic-bezier(0.16, 1, 0.3, 1)',
'&:hover': {
color: d3roPalette.text.primary,
bgcolor: isActive ? undefined : d3roPalette.glass.raised,
transform: 'translateX(2px)',
},
'&:active': {
transform: 'translateX(1px) scale(0.99)',
},
'& svg': {
color: isActive ? d3roPalette.accent.light : 'inherit',
@ -239,10 +268,42 @@ export function AppLayout(): React.ReactElement {
)
})}
{/* 스페이서 */}
<Box sx={{ flex: 1 }} />
{/* Customer Support Trigger */}
<Box
onClick={() => setSupportModalOpen(true)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.15,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: d3roPalette.text.inactive,
border: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.glass.surface,
transition: 'all 0.18s ease',
'&:hover': {
color: d3roPalette.tag.purpleText,
bgcolor: d3roPalette.glass.raised,
borderColor: d3roPalette.tag.purpleBorder,
},
}}
>
<Headphones size={ICON_SIZE} />
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
fontWeight: 500,
lineHeight: 1,
}}
>
Customer Support
</Typography>
</Box>
{/* Settings */}
{/* Settings Trigger */}
<Box
onClick={() => setSettingsOpen(true)}
sx={{
@ -250,65 +311,108 @@ export function AppLayout(): React.ReactElement {
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.25,
py: 1.15,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: d3roPalette.text.inactive,
transition: 'color 0.15s ease, background-color 0.15s ease',
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
border: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.glass.surface,
transition: 'all 0.18s ease',
'&:hover': {
color: d3roPalette.text.primary,
bgcolor: d3roPalette.glass.raised,
borderColor: d3roPalette.glass.hairlineStrong,
},
}}
>
<Settings size={ICON_SIZE} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.compact.size, fontWeight: 500, lineHeight: 1 }}>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
fontWeight: 500,
lineHeight: 1,
}}
>
{t('nav.settings')}
</Typography>
</Box>
</Box>
{/* ── 콘텐츠 영역: 앰비언트 배경 + 오버레이 스크롤 ── */}
{/* ── Content Viewport: Ambient Mesh Background + Overlay Scrollbars ── */}
<Box
component="main"
sx={{
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
position: 'relative',
bgcolor: d3roPalette.bg.app,
// 앰비언트 그라디언트 오브 (좌상단 액센트 / 우하단 퍼플 힌트)
background: `
radial-gradient(ellipse 55% 40% at 12% -5%, ${d3roPalette.accent.dim} 0%, transparent 60%),
radial-gradient(ellipse 50% 35% at 95% 105%, ${d3roPalette.tag.purpleBg} 0%, transparent 60%),
radial-gradient(ellipse 60% 45% at 15% -5%, ${d3roPalette.accent.dim} 0%, transparent 65%),
radial-gradient(ellipse 55% 40% at 95% 105%, ${d3roPalette.tag.purpleBg} 0%, transparent 65%),
${d3roPalette.bg.app}
`,
}}
>
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: 'os-theme-dark',
autoHide: 'move',
autoHideDelay: 800,
clickScroll: true,
},
}}
style={{ height: '100%', width: '100%' }}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
{currentRoute === 'meeting' && <MeetingModePage />}
</OverlayScrollbarsComponent>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: 'os-theme-dark',
autoHide: 'move',
autoHideDelay: 800,
clickScroll: true,
},
}}
style={{ height: '100%', width: '100%' }}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
{currentRoute === 'meeting' && <MeetingModePage />}
</OverlayScrollbarsComponent>
</Box>
{/* Free Tier Subtle Dock Banner */}
{currentTier === 'free' && (
<Box sx={{ px: 2, pb: 1, pt: 0.5, flexShrink: 0 }}>
<AdBanner
tier={currentTier}
onOpenUpgradeModal={() => setCheckoutModalOpen(true)}
/>
</Box>
)}
</Box>
</Box>
<StatusBar />
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<SettingsModal open={settingsOpen} initialTab={settingsInitialTab} onClose={() => setSettingsOpen(false)} />
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
<SupportModal open={supportModalOpen} onClose={() => setSupportModalOpen(false)} />
<CheckoutModal
open={checkoutModalOpen}
onClose={() => setCheckoutModalOpen(false)}
onSuccess={(newTier) => {
setCurrentTier(newTier)
setCheckoutModalOpen(false)
}}
/>
<RewardedQuotaModal
open={rewardedModalOpen}
onClose={() => setRewardedModalOpen(false)}
onRewardClaimed={(tokens) => {
alert(`🎉 ${tokens}개의 무료 클라우드 AI 정제 토큰이 충전되었습니다!`)
}}
/>
{/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */}
{/* Fallback Banner */}
<Snackbar
open={fallbackMsg !== null}
autoHideDuration={8000}
@ -320,7 +424,7 @@ export function AppLayout(): React.ReactElement {
</Alert>
</Snackbar>
{/* 음성 세션 에러/경고 배너 — 모델 미설치·엔진 실패·LLM 스킵 알림 */}
{/* Voice Alert Banner */}
<Snackbar
open={voiceAlert !== null}
autoHideDuration={6000}

View file

@ -0,0 +1,468 @@
// src/renderer/components/CodexOAuthGuideModal.tsx
// OpenAI API Key 및 Codex OAuth / ChatGPT 구독자 연동 인터랙티브 가이드 모달
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tabs,
Tab,
Box,
Typography,
Button,
IconButton,
Divider,
Paper,
Stack,
Chip,
Alert,
} from '@mui/material'
import { X, Key, ShieldCheck, ExternalLink, Copy, Check, Terminal, Zap, Sparkles, Globe } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
interface CodexOAuthGuideModalProps {
open: boolean
onClose: () => void
}
export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProps): React.ReactElement {
const [tab, setTab] = useState(0)
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
const handleCopy = (text: string, index: number) => {
navigator.clipboard.writeText(text)
setCopiedIndex(index)
setTimeout(() => setCopiedIndex(null), 2000)
}
const handleOpenLink = (url: string) => {
window.electronAPI.system.openExternal({ url })
}
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.default}`,
boxShadow: d3roShadow.chassis,
borderRadius: d3roRadius.md,
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 700,
fontSize: d3roTypo.body.size,
letterSpacing: d3roTypo.engrave.spacing,
textTransform: 'uppercase',
color: d3roPalette.accent.main,
py: 1.5,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Sparkles size={18} color={d3roPalette.accent.main} />
<span>OpenAI & Codex OAuth </span>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
<X size={18} />
</IconButton>
</DialogTitle>
<Tabs
value={tab}
onChange={(_, v: number) => setTab(v)}
sx={{
bgcolor: d3roPalette.bg.sidebar,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
minHeight: 44,
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
letterSpacing: '0.5px',
color: d3roPalette.text.inactive,
minHeight: 44,
'&.Mui-selected': { color: d3roPalette.accent.main },
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
}}
>
<Tab icon={<Key size={14} />} iconPosition="start" label="1. OpenAI API Key (추천)" />
<Tab icon={<Terminal size={14} />} iconPosition="start" label="2. Codex OAuth / ChatGPT 구독" />
<Tab icon={<Globe size={14} />} iconPosition="start" label="3. 타사 AI STT 무료 키 발급" />
</Tabs>
<DialogContent sx={{ bgcolor: d3roPalette.bg.app, p: 3 }}>
{/* Tab 0: Direct OpenAI API Key */}
{tab === 0 && (
<Stack spacing={2.5}>
<Alert
severity="info"
icon={<Zap size={18} />}
sx={{
bgcolor: d3roPalette.bg.inset,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
'& .MuiAlert-icon': { color: d3roPalette.accent.main },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
(30 )
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
OpenAI Whisper STT API는 ($0.006/, 1 500 ), ChatGPT .
</Typography>
</Alert>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
[STEP-BY-STEP ]
</Typography>
<Stack spacing={2}>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
1단계: OpenAI Platform
</Typography>
<Button
size="small"
variant="contained"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://platform.openai.com/api-keys')}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
API Keys
</Button>
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
OpenAI API Keys .
</Typography>
</Paper>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
2단계: (Secret Key)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, display: 'block', mb: 1 }}>
<b>"+ Create new secret key"</b> (<code>sk-proj-...</code>) .
</Typography>
<Chip
label="키는 최초 1회만 표시되므로 바로 복사해 두세요."
size="small"
sx={{
fontSize: '11px',
bgcolor: d3roPalette.tag.orangeBg,
color: d3roPalette.tag.orange,
border: `1px solid ${d3roPalette.border.default}`,
}}
/>
</Paper>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
3단계: D3RO-Voice STT
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
D3RO-Voice의 STT <b>OpenAI Whisper</b> , API Key <b>"연결 테스트"</b> .
</Typography>
</Paper>
</Stack>
</Box>
</Stack>
)}
{/* Tab 1: Codex OAuth / ChatGPT Subscription */}
{tab === 1 && (
<Stack spacing={2.5}>
<Alert
severity="warning"
icon={<ShieldCheck size={18} />}
sx={{
bgcolor: d3roPalette.bg.inset,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
'& .MuiAlert-icon': { color: d3roPalette.tag.orange },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
ChatGPT Plus / Pro / Team
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
ChatGPT OpenAI Platform API는 . Codex CLI OAuth(Device Flow) .
</Typography>
</Alert>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
[Codex Device Flow ]
</Typography>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
mb: 2,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
1. Device Auth
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: d3roPalette.bg.elevated,
p: 1.5,
borderRadius: d3roRadius.xs,
fontFamily: d3roFontMono,
fontSize: '12px',
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
}}
>
<code>codex login --device-auth</code>
<IconButton
size="small"
onClick={() => handleCopy('codex login --device-auth', 1)}
sx={{ color: d3roPalette.accent.main }}
>
{copiedIndex === 1 ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, mt: 1, display: 'block' }}>
8 .
</Typography>
</Paper>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
2. (Troubleshooting)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
<b>localhost </b>: VPN이나 <code>localhost</code> . <code>--device-auth</code> .
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary, display: 'block' }}>
<b> </b>: OAuth , API Key .
</Typography>
</Paper>
</Box>
</Stack>
)}
{/* Tab 2: Other AI STT Providers */}
{tab === 2 && (
<Stack spacing={2}>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 0.5 }}>
D3RO-Voice는 AI STT . :
</Typography>
<Stack spacing={1.5}>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Groq STT</Typography>
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 700 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
LPU Whisper Large V3 0.2 .
</Typography>
</Box>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://console.groq.com/keys')}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', whiteSpace: 'nowrap' }}
>
Groq
</Button>
</Paper>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Deepgram Nova-3</Typography>
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 700 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
. .
</Typography>
</Box>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://console.deepgram.com/')}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', whiteSpace: 'nowrap' }}
>
Deepgram
</Button>
</Paper>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Google Gemini 2.0 Flash Audio</Typography>
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 700 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
Google AI Studio에서 API .
</Typography>
</Box>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://aistudio.google.com/app/apikey')}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', whiteSpace: 'nowrap' }}
>
Google
</Button>
</Paper>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.sm,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AssemblyAI Universal-2</Typography>
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 700 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
.
</Typography>
</Box>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://www.assemblyai.com/dashboard')}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', whiteSpace: 'nowrap' }}
>
AssemblyAI
</Button>
</Paper>
</Stack>
</Stack>
)}
</DialogContent>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogActions sx={{ bgcolor: d3roPalette.bg.sidebar, px: 3, py: 1.5 }}>
<Button
onClick={onClose}
variant="contained"
sx={{
fontFamily: d3roFontMono,
fontWeight: 700,
fontSize: d3roTypo.label.size,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -5,7 +5,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material'
import { Copy, X, FileUp } from 'lucide-react'
import { MetalCard, PhosphorText, Led } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
FileTranscriptionProgress,
@ -51,9 +51,12 @@ export function FileDropZone(): React.ReactElement {
}
}, [])
const isBusyRef = useRef(false)
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
const file = e.dataTransfer.files[0]
if (!file) return
@ -65,38 +68,50 @@ export function FileDropZone(): React.ReactElement {
return
}
isBusyRef.current = true
setState('converting')
setError(null)
setResult(null)
const filePath = (file as unknown as { path: string }).path
const resp = await window.electronAPI.fileTranscription.start({ filePath })
if (!resp.success) {
setError(resp.error.message)
setState('error')
}
}, [t])
const handleBrowse = useCallback(async () => {
setState('converting')
setError(null)
setResult(null)
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
if (!resp.success) {
if (resp.error.message.includes('cancelled')) {
setState('idle')
} else {
try {
const filePath = (file as unknown as { path: string }).path
const resp = await window.electronAPI.fileTranscription.start({ filePath })
if (!resp.success) {
setError(resp.error.message)
setState('error')
}
} finally {
isBusyRef.current = false
}
}, [])
}, [t, state])
const handleBrowse = useCallback(async () => {
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
isBusyRef.current = true
setState('converting')
setError(null)
setResult(null)
try {
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
if (!resp.success) {
if (resp.error.message.includes('cancelled') || resp.error.message.includes('already active')) {
setState('idle')
} else {
setError(resp.error.message)
setState('error')
}
}
} finally {
isBusyRef.current = false
}
}, [state])
const handleCancel = useCallback(async () => {
await window.electronAPI.fileTranscription.cancel()
setState('idle')
setProgress(null)
isBusyRef.current = false
}, [])
const handleCopy = useCallback(() => {

View file

@ -15,7 +15,7 @@ import {
Stack,
Typography,
} from '@mui/material'
import { d3roPalette } from '@d3ro/ui/theme'
import { d3roPalette, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { HotkeyBinding } from '@d3ro/core/types'
import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey'

View file

@ -230,12 +230,22 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
// ── 공유 하위 컴포넌트 ──────────────────────────────────
function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement {
const color = tier === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.main
const label = tier === 'pro_plus' ? t('license.proPlus') : tier === 'pro' ? t('license.pro') : t('license.free')
const color = tier === 'enterprise' || tier === 'team'
? d3roPalette.accent.main
: tier === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.main
const label = tier === 'enterprise'
? 'Enterprise'
: tier === 'team'
? 'Team'
: tier === 'pro_plus'
? t('license.proPlus')
: tier === 'pro'
? t('license.pro')
: t('license.free')
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}

View file

@ -1,12 +1,11 @@
// src/renderer/components/LicenseTab.tsx
// 구독 기반 라이선스 탭 — 클라우드 인증 + Payple 결제 연동
import { useState } from 'react'
import {
Box,
Divider,
LinearProgress,
TextField,
} from '@mui/material'
import { CircleCheck, XCircle } from 'lucide-react'
import { CircleCheck, XCircle, KeyRound, Sparkles } from 'lucide-react'
import { Led, PhosphorText, PhysicalButton, MetalCard } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
@ -45,6 +44,23 @@ export function LicenseTab(): React.ReactElement {
</Box>
</MetalCard>
{/* ── 14일 Reverse-Trial 상태 배너 (활성화 시) ── */}
{licenseInfo?.isTrial && licenseInfo.trialExpiresAt && (
<MetalCard sx={{ bgcolor: 'rgba(255, 170, 0, 0.08)', borderColor: d3roPalette.accent.main }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Sparkles size={18} color={d3roPalette.accent.main} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 'bold' }}>
14-Day Reverse Trial (Pro+)
</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{Math.max(0, Math.ceil((licenseInfo.trialExpiresAt - Date.now()) / (24 * 60 * 60 * 1000)))} 100% .
</PhosphorText>
</Box>
</Box>
</MetalCard>
)}
{/* ── 구독 관리 / 업그레이드 ── */}
<MetalCard>
<SubscriptionSection
@ -59,6 +75,11 @@ export function LicenseTab(): React.ReactElement {
/>
</MetalCard>
{/* ── 오프라인 라이선스 키 등록 ── */}
<MetalCard>
<LicenseKeySection t={t} currentTier={currentTier} />
</MetalCard>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* ── 일일 사용량 ── */}
@ -73,15 +94,115 @@ export function LicenseTab(): React.ReactElement {
// ── 하위 컴포넌트 ──────────────────────────────────────
function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement {
const color = tier === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.main
const label = tier === 'pro_plus' ? t('license.proPlus') : tier === 'pro' ? t('license.pro') : t('license.free')
const color = tier === 'enterprise' || tier === 'team'
? d3roPalette.accent.main
: tier === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.main
const label = tier === 'enterprise'
? 'Enterprise'
: tier === 'team'
? 'Team'
: tier === 'pro_plus'
? t('license.proPlus')
: tier === 'pro'
? t('license.pro')
: t('license.free')
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}
function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; currentTier: string }): React.ReactElement {
const [licenseKeyInput, setLicenseKeyInput] = useState('')
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
const [loading, setLoading] = useState(false)
const handleActivateKey = async () => {
if (!licenseKeyInput.trim()) return
setLoading(true)
setFeedback(null)
try {
const res = await window.electronAPI.license.activate({ licenseKey: licenseKeyInput.trim() })
if (res.success && res.data.success) {
setFeedback({ success: true, message: res.data.message || '라이센스가 성공적으로 활성화되었습니다.' })
setLicenseKeyInput('')
} else {
setFeedback({ success: false, message: (res.success && res.data.message) ? res.data.message : '유효하지 않은 라이센스 키입니다.' })
}
} catch (err) {
setFeedback({ success: false, message: `활성화 실패: ${err}` })
} finally {
setLoading(false)
}
}
const handleDeactivate = async () => {
setLoading(true)
try {
await window.electronAPI.license.deactivate()
setFeedback({ success: true, message: '라이센스가 비활성화되어 Free 티어로 전환되었습니다.' })
} finally {
setLoading(false)
}
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyRound size={16} color={d3roPalette.text.label} />
<PhosphorText variant="label">
(Ed25519)
</PhosphorText>
</Box>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
(D3RO-LIC-... D3RO-PRO-...) .
</PhosphorText>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
placeholder="D3RO-LIC-..."
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
disabled={loading}
sx={{
fontFamily: d3roFontMono,
'& .MuiOutlinedInput-root': {
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.button,
fontSize: d3roTypo.small.size,
fontFamily: d3roFontMono,
color: d3roPalette.text.primary,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.main },
},
}}
/>
<PhysicalButton onClick={handleActivateKey} disabled={loading || !licenseKeyInput.trim()} sx={{ px: 2.5, whiteSpace: 'nowrap' }}>
<PhosphorText variant="compact"></PhosphorText>
</PhysicalButton>
{currentTier !== 'free' && (
<PhysicalButton onClick={handleDeactivate} disabled={loading} sx={{ px: 2, whiteSpace: 'nowrap' }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.red }}></PhosphorText>
</PhysicalButton>
)}
</Box>
{feedback && (
<PhosphorText
variant="small"
sx={{ color: feedback.success ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{feedback.message}
</PhosphorText>
)}
</Box>
)
}
interface SubscriptionSectionProps {
t: (k: string) => string
authenticated: boolean
@ -134,6 +255,28 @@ function SubscriptionSection({ t, authenticated, isFree, isPro, onUpgrade, onMan
)
}
if (currentTier === 'team') {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ActivePlanIndicator label="Team Workspace Plan Active" color={d3roPalette.accent.main} />
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
</PhysicalButton>
</Box>
)
}
if (currentTier === 'enterprise') {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ActivePlanIndicator label="Enterprise Dedicated Plan Active" color={d3roPalette.accent.main} />
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
</PhysicalButton>
</Box>
)
}
// Pro+
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>

View file

@ -1,6 +1,7 @@
// src/renderer/components/OllamaGuideModal.tsx
// Ollama 설치/설정 안내 모달
// Ollama 설치/설정/모델 다운로드 종합 인터랙티브 모달 (v2 Midnight Glass)
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
@ -11,63 +12,291 @@ import {
Button,
Divider,
IconButton,
Tooltip,
Paper,
Chip,
LinearProgress,
TextField,
CircularProgress,
Alert,
} from '@mui/material'
import { X, ExternalLink, Copy } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme'
import {
X,
ExternalLink,
Copy,
Check,
RefreshCw,
Play,
Download,
CheckCircle2,
Cpu,
Sparkles,
Zap,
} from 'lucide-react'
import {
d3roPalette,
d3roFontSans,
d3roFontMono,
d3roTypo,
d3roShadow,
d3roRadius,
typoSx,
} from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { useI18n } from '@d3ro/i18n'
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
interface OllamaGuideModalProps {
export interface OllamaGuideModalProps {
open: boolean
onClose: () => void
}
function CodeBlock({ children }: { children: string }): React.ReactElement {
interface RecommendedModel {
id: string
name: string
size: string
description: string
recommended?: boolean
}
const RECOMMENDED_MODELS: RecommendedModel[] = [
{
id: 'gemma2:2b',
name: 'Gemma 2 (2B)',
size: '1.6 GB',
description: '구글 경량 모델. 빠른 응답 속도와 저사양 PC에 최적화 (기본 추천)',
recommended: true,
},
{
id: 'llama3.2:3b',
name: 'Llama 3.2 (3B)',
size: '2.0 GB',
description: '메타의 최신 경량 모델. 텍스트 다듬기 및 문법 교정에 우수',
},
{
id: 'qwen2.5:3b',
name: 'Qwen 2.5 (3B)',
size: '1.9 GB',
description: '한국어 및 다국어 이해도가 매우 뛰어난 고성능 모델',
},
{
id: 'gemma2:9b',
name: 'Gemma 2 (9B)',
size: '5.4 GB',
description: '고성능 모델. 복잡한 요약 및 번역에 적합 (RAM 16GB+ 권장)',
},
]
function CodeBlock({ code }: { code: string }): React.ReactElement {
const { t } = useI18n()
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [code])
return (
<Box
sx={{
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
p: 1.5,
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
color: d3roPalette.accent.main,
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: d3roShadow.inset,
}}
>
<span>{children}</span>
<IconButton
size="small"
onClick={() => navigator.clipboard.writeText(children)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}
<Typography
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
color: d3roPalette.accent.light,
letterSpacing: '0.02em',
userSelect: 'all',
}}
>
<Copy size={14} />
</IconButton>
{code}
</Typography>
<Tooltip title={copied ? t('common.copy') : t('common.copy')} placement="top">
<IconButton
size="small"
onClick={handleCopy}
sx={{
color: copied ? d3roPalette.tag.green : d3roPalette.text.inactive,
p: 0.5,
ml: 1,
'&:hover': {
color: copied ? d3roPalette.tag.green : d3roPalette.accent.light,
bgcolor: d3roPalette.glass.raised,
},
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Tooltip>
</Box>
)
}
export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement {
const { t } = useI18n()
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b')
const [checking, setChecking] = useState(false)
const [starting, setStarting] = useState(false)
const [startMessage, setStartMessage] = useState<string | null>(null)
const handleOpenLink = (url: string) => {
// Download state
const [pullingModelId, setPullingModelId] = useState<string | null>(null)
const [pullProgress, setPullProgress] = useState<{
status: string
percent: number
completed: number
total: number
}>({ status: '', percent: 0, completed: 0, total: 0 })
// Test prompt state
const [testPrompt, setTestPrompt] = useState('안녕하세요 오늘의 날씨를 알려줘')
const [testResult, setTestResult] = useState<string | null>(null)
const [testing, setTesting] = useState(false)
const refreshStatus = useCallback(async () => {
setChecking(true)
try {
const res = await window.electronAPI.llm.checkConnection()
if (res.success && res.data) {
setInstalledModels(res.data.models)
}
const st = await window.electronAPI.llm.getStatus()
if (st.success) {
setLlmStatus(st.data)
}
const active = await window.electronAPI.llm.getActiveModel()
if (active.success && active.data) {
setActiveModel(active.data)
}
} finally {
setChecking(false)
}
}, [])
useEffect(() => {
if (!open) return
refreshStatus()
const unsubPull = window.electronAPI.llm.onPullProgress((e) => {
setPullingModelId(e.modelId)
setPullProgress({
status: e.status,
percent: e.percent,
completed: e.completed,
total: e.total,
})
if (e.percent >= 100 || e.status === 'success') {
setTimeout(() => {
setPullingModelId(null)
refreshStatus()
}, 1200)
}
})
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
setLlmStatus(e.status)
})
return () => {
unsubPull()
unsubStatus()
}
}, [open, refreshStatus])
const handleOpenLink = useCallback((url: string) => {
window.electronAPI.system.openExternal({ url })
}
}, [])
const handleStartOllama = useCallback(async () => {
setStarting(true)
setStartMessage(null)
try {
const res = await window.electronAPI.llm.startServer()
if (res.success) {
if (res.data === 'running') {
setStartMessage('Ollama가 이미 실행 중입니다.')
} else if (res.data === 'starting') {
setStartMessage('Ollama 서버를 시작했습니다. 연결 확인 중...')
setTimeout(() => refreshStatus(), 2000)
} else if (res.data === 'not-installed') {
setStartMessage('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.')
} else {
setStartMessage('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.')
}
}
} finally {
setStarting(false)
}
}, [refreshStatus])
const handlePullModel = useCallback(async (modelId: string) => {
setPullingModelId(modelId)
setPullProgress({ status: '다운로드 시작 중...', percent: 0, completed: 0, total: 0 })
try {
await window.electronAPI.llm.pullModel({ modelId })
} catch {
setPullingModelId(null)
}
}, [])
const handleSelectModel = useCallback(async (modelId: string) => {
await window.electronAPI.llm.setModel({ modelId })
await window.electronAPI.config.set({ key: 'llmModelId', value: modelId })
setActiveModel(modelId)
await refreshStatus()
}, [refreshStatus])
const handleRunTest = useCallback(async () => {
if (!testPrompt.trim()) return
setTesting(true)
setTestResult(null)
try {
const res = await window.electronAPI.llm.process({
text: testPrompt,
action: 'refine',
})
if (res.success && res.data) {
setTestResult(res.data.processedText)
} else {
setTestResult(`오류: ${res.error?.message ?? '응답 실패'}`)
}
} catch (err) {
setTestResult(`실패: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setTesting(false)
}
}, [testPrompt])
const isConnected = llmStatus?.connectionState === 'connected'
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.chassis,
bgcolor: d3roPalette.bg.card,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.card,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.dialog,
overflow: 'hidden',
},
}}
>
@ -76,86 +305,382 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 700,
fontSize: '14px',
letterSpacing: '1px',
color: d3roPalette.accent.main,
py: 1.5,
py: 2,
px: 3,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
{t('ollama.title')}
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isConnected ? 'green' : 'amber'} pulse={!isConnected} size={9} />
<Typography
sx={{
fontFamily: d3roFontSans,
fontWeight: 700,
fontSize: d3roTypo.heading.size,
color: d3roPalette.text.primary,
}}
>
Ollama AI &
</Typography>
</Box>
<IconButton
onClick={onClose}
size="small"
sx={{
color: d3roPalette.text.inactive,
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
}}
>
<X size={18} />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent sx={{ bgcolor: d3roPalette.bg.app }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, py: 1 }}>
{/* Step 1 */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" size={8} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
{t('ollama.step1.title')}
<DialogContent sx={{ px: 3, py: 2.5, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── 실시간 상태 바 ── */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${isConnected ? d3roPalette.tag.green : d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isConnected ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ ...typoSx('heading'), fontSize: '13px', color: isConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{isConnected
? `Ollama 연결 완료 ${llmStatus?.serverVersion ? `(v${llmStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
URL: {llmStatus?.serverUrl || 'http://localhost:11434'} | : {activeModel || '—'}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 1.5 }}>
{t('ollama.step1.desc')}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checking ? 'animate-spin' : ''} />}
onClick={refreshStatus}
disabled={checking}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
borderColor: d3roPalette.glass.hairlineStrong,
color: d3roPalette.text.primary,
}}
>
</Button>
{!isConnected && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={handleStartOllama}
disabled={starting}
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
fontWeight: 700,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{starting ? '실행 중...' : 'Ollama 자동 실행'}
</Button>
)}
</Box>
</Paper>
{startMessage && (
<Alert severity={isConnected ? 'success' : 'info'} sx={{ py: 0.5 }}>
{startMessage}
</Alert>
)}
{/* ── Step 1: Ollama 설치 및 기동 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 1" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
Ollama
</Typography>
</Box>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, pl: 1 }}>
Ollama는 PC 100% , LLM을 .
</Typography>
<Box sx={{ pl: 1, display: 'flex', gap: 1.5, flexWrap: 'wrap', pt: 0.5 }}>
<Button
variant="outlined"
size="small"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://ollama.com/download')}
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
borderColor: d3roPalette.accent.main,
color: d3roPalette.accent.light,
'&:hover': {
borderColor: d3roPalette.accent.main,
bgcolor: d3roPalette.glass.raised,
},
}}
>
ollama.com/download
1. Ollama (ollama.com/download)
</Button>
<Button
variant="outlined"
size="small"
startIcon={<Play size={14} />}
onClick={handleStartOllama}
disabled={starting}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
borderColor: d3roPalette.glass.hairlineStrong,
color: d3roPalette.text.primary,
}}
>
2.
</Button>
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.glass.hairline }} />
{/* ── Step 2: 추천 모델 다운로드 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 2" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
1-
</Typography>
</Box>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel, fontFamily: d3roFontMono }}>
: {installedModels.length}
</Typography>
</Box>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, pl: 1 }}>
, , AI .
</Typography>
{/* 모델 카드 그리드 */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5, pl: 1 }}>
{RECOMMENDED_MODELS.map((model) => {
const isInstalled = installedModels.some((m) => m.name === model.id || m.name.startsWith(model.id.split(':')[0]))
const isActive = activeModel === model.id || activeModel.startsWith(model.id.split(':')[0])
const isDownloading = pullingModelId === model.id
return (
<Paper
key={model.id}
elevation={0}
sx={{
p: 1.75,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${isActive ? d3roPalette.accent.main : d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 1,
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: d3roPalette.text.primary }}>
{model.name}
</Typography>
{model.recommended && (
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 700, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
)}
</Box>
<Chip label={model.size} size="small" sx={{ height: 18, fontSize: '10px', fontFamily: d3roFontMono }} />
</Box>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary, lineHeight: 1.4 }}>
{model.description}
</Typography>
</Box>
{/* 다운로드 진행률 또는 액션 버튼 */}
{isDownloading ? (
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontSize: '10px', color: d3roPalette.accent.light }}>
{pullProgress.status || '다운로드 중...'}
</Typography>
<Typography sx={{ fontSize: '10px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
{pullProgress.percent}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={pullProgress.percent} sx={{ height: 4, borderRadius: 2 }} />
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 0.5 }}>
{isInstalled ? (
<Button
size="small"
variant={isActive ? 'contained' : 'outlined'}
onClick={() => handleSelectModel(model.id)}
startIcon={isActive ? <CheckCircle2 size={13} /> : undefined}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
py: 0.25,
bgcolor: isActive ? d3roPalette.tag.green : 'transparent',
borderColor: isActive ? d3roPalette.tag.green : d3roPalette.glass.hairlineStrong,
color: isActive ? d3roPalette.bg.app : d3roPalette.text.primary,
'&:hover': {
bgcolor: isActive ? d3roPalette.tag.green : d3roPalette.glass.raised,
},
}}
>
{isActive ? '사용 중' : '기본으로 선택'}
</Button>
) : (
<Button
size="small"
variant="contained"
startIcon={<Download size={13} />}
onClick={() => handlePullModel(model.id)}
disabled={!isConnected || Boolean(pullingModelId)}
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
fontWeight: 700,
py: 0.25,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
(Pull)
</Button>
)}
</Box>
)}
</Paper>
)
})}
</Box>
{/* 터미널 명령어 수동 입력 안내 */}
<Box sx={{ pl: 1, display: 'flex', flexDirection: 'column', gap: 1, pt: 1 }}>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}>
:
</Typography>
<CodeBlock code="ollama pull gemma2:2b" />
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.glass.hairline }} />
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 3" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
(AI )
</Typography>
</Box>
<Box sx={{ pl: 1, display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
value={testPrompt}
onChange={(e) => setTestPrompt(e.target.value)}
placeholder="테스트할 음성 전사 문장 입력"
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '12px',
bgcolor: d3roPalette.bg.inset,
},
}}
/>
<Button
variant="contained"
size="small"
onClick={handleRunTest}
disabled={!isConnected || testing}
startIcon={testing ? <CircularProgress size={12} color="inherit" /> : <Zap size={14} />}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
minWidth: 100,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{testing ? '처리 중' : '테스트'}
</Button>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Step 2 */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" size={8} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
{t('ollama.step2.title')}
{testResult && (
<Paper
elevation={0}
sx={{
ml: 1,
p: 1.5,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
}}
>
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 700, mb: 0.5 }}>
AI ({activeModel}):
</Typography>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 1.5 }}>
{t('ollama.step2.desc')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<CodeBlock>ollama pull gemma4:e4b</CodeBlock>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
{t('ollama.step2.alt')}
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
{testResult}
</Typography>
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Step 3 */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="green" size={8} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', fontWeight: 700 }}>
{t('ollama.step3.title')}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
{t('ollama.step3.desc')}
</Typography>
</Box>
</Paper>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, bgcolor: d3roPalette.bg.app }}>
<Button onClick={onClose} variant="contained">
<DialogActions
sx={{
px: 3,
py: 2,
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
justifyContent: 'space-between',
}}
>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}>
D3RO Voice는 100% .
</Typography>
<Button
onClick={onClose}
variant="contained"
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.small.size,
fontWeight: 600,
borderRadius: d3roRadius.button,
}}
>
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -1,16 +1,7 @@
// src/renderer/components/OnboardingModal.tsx
// 첫 실행 온보딩 모달 — 기본 모델 미설치 시 다운로드 유도.
//
// 2단계 부트스트랩:
// 1) LLM: gemma4:e4b (Ollama pull, ~9.6GB)
// 2) STT: Whisper large-v3-turbo (sidecar 사전 다운로드, ~1.6GB)
// 필요한 단계만 실행하며(멱등), 모두 성공 시 config.onboardingCompleted=true 저장.
//
// 두 경로로 열림:
// 1) AppLayout의 첫 실행 감지(onboardingCompleted=false)
// 2) 런타임 중 모델 미설치 감지 (주기적 polling)
// 첫 실행 온보딩 모달 — 사용 방식 선택 (로컬 Ollama 독립 모드 vs 온라인 클라우드 API)
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
@ -20,251 +11,214 @@ import {
Typography,
Box,
LinearProgress,
TextField,
Tab,
Tabs,
Paper,
Chip,
Alert,
} from '@mui/material'
import { CheckCircle2, AlertCircle, CloudDownload } from 'lucide-react'
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import {
CheckCircle2,
AlertCircle,
Cloud,
HardDrive,
Lock,
Download,
Play,
RefreshCw,
ExternalLink,
Cpu,
Sparkles,
ArrowRight,
ShieldCheck,
} from 'lucide-react'
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import type { LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
const DEFAULT_LLM_MODEL = 'gemma4:e4b'
const FALLBACK_STT_MODEL = 'large-v3-turbo'
type StepKind = 'llm' | 'stt'
type Phase = 'prompt' | 'downloading' | 'success' | 'failed'
interface NeededSteps {
steps: StepKind[]
sttModelId: string
}
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
interface OnboardingModalProps {
open: boolean
onClose: () => void
}
/** 설치가 필요한 단계 목록을 계산한다 (멱등 — 이미 설치된 단계는 제외) */
async function computeNeededSteps(): Promise<NeededSteps> {
const steps: StepKind[] = []
// 1) LLM 모델 존재 여부
try {
const llmResult = await window.electronAPI.llm.getModels()
const hasLlm =
llmResult.success && llmResult.data.some((m) => m.id === DEFAULT_LLM_MODEL)
if (!hasLlm) steps.push('llm')
} catch {
steps.push('llm')
}
// 2) STT 모델 다운로드 여부 (현재 설정된 모델 기준)
let sttModelId = FALLBACK_STT_MODEL
try {
const activeResult = await window.electronAPI.stt.getActiveModel()
if (activeResult.success && activeResult.data) {
sttModelId = activeResult.data
}
const modelsResult = await window.electronAPI.stt.getModels()
const downloaded =
modelsResult.success &&
modelsResult.data.some((m) => m.id === sttModelId && m.downloaded)
if (!downloaded) steps.push('stt')
} catch {
steps.push('stt')
}
return { steps, sttModelId }
}
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
const { t } = useI18n()
const [internalOpen, setInternalOpen] = useState(false)
const [phase, setPhase] = useState<Phase>('prompt')
const [neededSteps, setNeededSteps] = useState<StepKind[]>([])
const [stepIndex, setStepIndex] = useState(0)
const [sttModelId, setSttModelId] = useState(FALLBACK_STT_MODEL)
const [percent, setPercent] = useState(0)
const [status, setStatus] = useState('')
const [detail, setDetail] = useState('')
const [phase, setPhase] = useState<Phase>('select_mode')
// Local Ollama State
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b')
const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaMsg, setOllamaMsg] = useState<string | null>(null)
// Model download state
const [pullingModel, setPullingModel] = useState<string | null>(null)
const [pullProgress, setPullProgress] = useState({ status: '', percent: 0 })
// Auth State
const [authTab, setAuthTab] = useState<'login' | 'register'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [authError, setAuthError] = useState('')
const [authLoading, setAuthLoading] = useState(false)
// Error State
const [errorMsg, setErrorMsg] = useState('')
// 진행률 없는 상태(무결성 검증 등) — 인디터미넌트 바로 "멈춘 것처럼" 보이는 문제 방지
const [busyStatus, setBusyStatus] = useState(false)
const activeStepRef = useRef<StepKind | null>(null)
const runningRef = useRef(false)
const isVisible = open || internalOpen
// 모델 존재 여부 체크 — 없으면 auto-open
const checkModels = useCallback(async (): Promise<void> => {
const checkOllama = useCallback(async () => {
setCheckingOllama(true)
try {
const needed = await computeNeededSteps()
setSttModelId(needed.sttModelId)
if (needed.steps.length > 0) {
setNeededSteps(needed.steps)
setPhase('prompt')
setInternalOpen(true)
} else {
setInternalOpen(false)
const res = await window.electronAPI.llm.checkConnection()
if (res.success && res.data) {
setInstalledModels(res.data.models)
}
} catch {
setPhase('prompt')
setInternalOpen(true)
}
}, [])
useEffect(() => {
const initialCheck = setTimeout(() => void checkModels(), 2000)
const interval = setInterval(() => {
if (phase === 'prompt') void checkModels()
}, 15000)
return () => {
clearTimeout(initialCheck)
clearInterval(interval)
}
}, [checkModels, phase])
// LLM pull 진행률 구독
useEffect(() => {
const unsub = window.electronAPI.llm.onPullProgress((e) => {
if (activeStepRef.current !== 'llm') return
if (e.modelId !== DEFAULT_LLM_MODEL) return
setStatus(e.status)
if (e.percent > 0) setPercent(e.percent)
// total 없는 상태 라인(pulling manifest / verifying / writing manifest)은
// 바이트 진행률이 없음 — 인디터미넌트로 표시
setBusyStatus(!e.total || e.total === 0)
})
return () => {
unsub()
}
}, [])
// STT 다운로드 진행률 구독
useEffect(() => {
const unsub = window.electronAPI.stt.onDownloadProgress((e) => {
if (activeStepRef.current !== 'stt') return
setPercent(e.percent)
if (e.totalBytes > 0) {
setDetail(
t('onboarding.progressDetail', {
downloaded: String(Math.round(e.downloadedBytes / 1e6)),
total: String(Math.round(e.totalBytes / 1e6)),
}),
)
const st = await window.electronAPI.llm.getStatus()
if (st.success) {
setOllamaStatus(st.data)
}
})
return () => {
unsub()
}
}, [t])
const handleDownload = useCallback(async (): Promise<void> => {
// 중복 실행 가드 (연타/이중 트리거 방지)
if (runningRef.current) return
runningRef.current = true
setPhase('downloading')
setErrorMsg('')
try {
// 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산
const needed = await computeNeededSteps()
setSttModelId(needed.sttModelId)
setNeededSteps(needed.steps)
for (let i = 0; i < needed.steps.length; i++) {
const step = needed.steps[i]
setStepIndex(i)
setPercent(0)
setStatus('')
setDetail('')
setBusyStatus(false)
activeStepRef.current = step
const result =
step === 'llm'
? await window.electronAPI.llm.pullModel({ modelId: DEFAULT_LLM_MODEL })
: await window.electronAPI.stt.downloadModel({ modelId: needed.sttModelId })
if (!result.success) {
setPhase('failed')
setErrorMsg(result.error?.message ?? 'unknown')
return
}
setPercent(100)
}
setPhase('success')
// 온보딩 완료 플래그 저장
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
} finally {
activeStepRef.current = null
runningRef.current = false
setCheckingOllama(false)
}
}, [])
const handleClose = useCallback(() => {
if (phase === 'downloading') return
setInternalOpen(false)
onClose()
}, [phase, onClose])
useEffect(() => {
window.electronAPI.config.getAll().then((res) => {
if (res.success && !res.data.onboardingCompleted) {
setInternalOpen(true)
setPhase('select_mode')
}
})
}, [])
if (!isVisible) return <></>
useEffect(() => {
if (!isVisible) return
const isDownloading = phase === 'downloading'
const isSuccess = phase === 'success'
const isFailed = phase === 'failed'
const unsubPull = window.electronAPI.llm.onPullProgress((e) => {
setPullingModel(e.modelId)
setPullProgress({ status: e.status, percent: e.percent })
if (e.percent >= 100 || e.status === 'success') {
setTimeout(() => {
setPullingModel(null)
checkOllama()
}, 1200)
}
})
const colorSuccess = d3roPalette.tag.green
const colorDanger = d3roPalette.tag.red
const colorAccent = d3roPalette.accent.main
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
setOllamaStatus(e.status)
})
const currentStep: StepKind | null = isDownloading
? (neededSteps[stepIndex] ?? null)
: null
return () => {
unsubPull()
unsubStatus()
}
}, [isVisible, checkOllama])
// Ollama 원시 status 문자열 → 친화적 문구 (알려진 것만 매핑, 그 외 원문)
const formatStatus = (raw: string): string => {
if (raw.startsWith('pulling manifest')) return t('onboarding.statusPreparing')
if (raw.startsWith('verifying')) return t('onboarding.statusVerifying')
if (raw.startsWith('writing manifest') || raw === 'success')
return t('onboarding.statusFinalizing')
return raw
const handleSelectLocalMode = async () => {
setPhase('local_ollama_setup')
await window.electronAPI.config.set({ key: 'appUsageMode', value: 'local' })
await window.electronAPI.config.set({ key: 'llmBackend', value: 'local' })
await window.electronAPI.config.set({ key: 'sttProvider', value: 'local' })
await checkOllama()
// 자동으로 Ollama 기동 시도
await window.electronAPI.llm.startServer()
setTimeout(() => checkOllama(), 1500)
}
/** 단계별 안내 박스 렌더 */
const renderStepInfo = (step: StepKind): React.ReactElement => (
<Box
key={step}
sx={{
p: 2,
borderRadius: d3roRadius.inner,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.subtle}`,
mb: 2,
opacity: currentStep === null || currentStep === step ? 1 : 0.5,
}}
>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
{step === 'llm'
? t('onboarding.llmModelMissing', { model: DEFAULT_LLM_MODEL })
: t('onboarding.sttModelMissing', { model: sttModelId })}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{step === 'llm' ? t('onboarding.llmModelSize') : t('onboarding.sttModelSize')}
</Typography>
</Box>
)
const handleStartOllama = async () => {
setStartingOllama(true)
setOllamaMsg(null)
try {
const res = await window.electronAPI.llm.startServer()
if (res.success) {
if (res.data === 'running') {
setOllamaMsg('Ollama가 이미 실행 중입니다.')
} else if (res.data === 'starting') {
setOllamaMsg('Ollama 서버를 시작했습니다. 연결 확인 중...')
setTimeout(() => checkOllama(), 2000)
} else if (res.data === 'not-installed') {
setOllamaMsg('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.')
} else {
setOllamaMsg('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.')
}
}
} finally {
setStartingOllama(false)
}
}
const handlePullModel = async (modelId: string) => {
setPullingModel(modelId)
setPullProgress({ status: '다운로드 시작 중...', percent: 0 })
try {
await window.electronAPI.llm.pullModel({ modelId })
await window.electronAPI.llm.setModel({ modelId })
await window.electronAPI.config.set({ key: 'llmModelId', value: modelId })
setActiveModel(modelId)
} catch {
setPullingModel(null)
}
}
const handleCompleteLocalSetup = async () => {
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
setPhase('success')
}
const handleAuthSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setAuthError('')
setAuthLoading(true)
try {
const res = authTab === 'login'
? await window.electronAPI.onlineAuth.login({ email, password })
: await window.electronAPI.onlineAuth.register({ email, password })
if (res.success) {
await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' })
await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' })
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
setPhase('success')
} else {
setAuthError(res.error?.message ?? '인증에 실패하였습니다.')
}
} catch (err) {
setAuthError(`오류: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setAuthLoading(false)
}
}
const handleClose = () => {
setInternalOpen(false)
onClose()
}
const isOllamaConnected = ollamaStatus?.connectionState === 'connected'
const hasInstalledLlm = installedModels.length > 0
if (!isVisible) return <></>
return (
<Dialog
open={isVisible}
onClose={handleClose}
maxWidth="sm"
onClose={() => {}}
maxWidth="md"
fullWidth
disableEscapeKeyDown={isDownloading}
PaperProps={{
sx: {
borderRadius: d3roRadius.card,
backgroundColor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.subtle}`,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
p: 1,
},
}}
>
@ -276,122 +230,398 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
...typoSx('heading'),
color: d3roPalette.text.primary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
pb: 1.5,
}}
>
{isSuccess ? (
<CheckCircle2 size={24} style={{ color: colorSuccess }} />
) : isFailed ? (
<AlertCircle size={24} style={{ color: colorDanger }} />
{phase === 'success' ? (
<CheckCircle2 size={24} style={{ color: d3roPalette.tag.green }} />
) : phase === 'failed' ? (
<AlertCircle size={24} style={{ color: d3roPalette.tag.red }} />
) : (
<CloudDownload size={24} style={{ color: colorAccent }} />
<Sparkles size={24} style={{ color: d3roPalette.accent.main }} />
)}
{t('onboarding.title')}
D3RO Voice
</DialogTitle>
<DialogContent sx={{ py: 3 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.subtitle')}
</Typography>
{/* ── 1. 사용 모드 선택 ── */}
{phase === 'select_mode' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary }}>
D3RO Voice를 ? .
</Typography>
{(phase === 'prompt' || isDownloading) && neededSteps.map(renderStepInfo)}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
{/* 로컬 AI 독립 모드 (강력 권장) */}
<Paper
elevation={0}
onClick={handleSelectLocalMode}
sx={{
p: 2.5,
cursor: 'pointer',
borderRadius: d3roRadius.card,
bgcolor: d3roPalette.bg.elevated,
border: `2px solid ${d3roPalette.accent.main}`,
transition: 'all 150ms ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
bgcolor: d3roPalette.bg.inset,
transform: 'translateY(-2px)',
boxShadow: d3roShadow.dialog,
},
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={22} color={d3roPalette.accent.main} />
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
AI
</Typography>
</Box>
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
</Box>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
<b>Ollama</b> <b>Whisper STT</b> PC .
</Typography>
{isDownloading && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{neededSteps.length > 1
? `${t('onboarding.step', {
current: String(stepIndex + 1),
total: String(neededSteps.length),
})} ${t('onboarding.downloading')}`
: t('onboarding.downloading')}
</Typography>
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
{busyStatus ? '…' : `${percent}%`}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{[
'인터넷 연결 없이 오프라인 작동',
'음성/텍스트 데이터 외부 유출 제로 (완벽한 보안)',
'API 비용 없이 무제한 전사 및 AI 다듬기',
].map((item) => (
<Box key={item} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldCheck size={14} color={d3roPalette.tag.green} />
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.primary }}>
{item}
</Typography>
</Box>
))}
</Box>
</Box>
<Button
variant="contained"
fullWidth
endIcon={<ArrowRight size={14} />}
sx={{
mt: 2.5,
fontFamily: d3roFontSans,
fontWeight: 700,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
AI
</Button>
</Paper>
{/* 온라인 클라우드 모드 */}
<Paper
elevation={0}
onClick={() => setPhase('online_auth')}
sx={{
p: 2.5,
cursor: 'pointer',
borderRadius: d3roRadius.card,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.default}`,
transition: 'all 150ms ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
bgcolor: d3roPalette.bg.elevated,
borderColor: d3roPalette.border.subtle,
},
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={22} color={d3roPalette.tag.blue} />
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
</Typography>
</Box>
<Chip label="계정 로그인" size="small" sx={{ fontSize: '10px', height: 20 }} />
</Box>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
AI .
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{[
'로컬 모델 설치 불필요 (저사양 PC용)',
'클라우드 동기화 및 다중 기기 연동',
'D3RO 계정 로그인 필요',
].map((item) => (
<Box key={item} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color={d3roPalette.tag.blue} />
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.primary }}>
{item}
</Typography>
</Box>
))}
</Box>
</Box>
<Button
variant="outlined"
fullWidth
endIcon={<ArrowRight size={14} />}
sx={{
mt: 2.5,
fontFamily: d3roFontSans,
borderColor: d3roPalette.border.default,
color: d3roPalette.text.primary,
}}
>
</Button>
</Paper>
</Box>
<LinearProgress
variant={busyStatus ? 'indeterminate' : 'determinate'}
value={percent}
sx={{ height: 8, borderRadius: d3roRadius.xs }}
/>
{detail && (
<Typography
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
{detail}
</Typography>
)}
{status && (
<Typography
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
{t('onboarding.status', { status: formatStatus(status) })}
</Typography>
)}
</Box>
)}
{isSuccess && (
<Typography sx={{ ...typoSx('body'), color: colorSuccess, mt: 2 }}>
{t('onboarding.success')}
</Typography>
{/* ── 2. 로컬 Ollama 온보딩 스텝 ── */}
{phase === 'local_ollama_setup' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* 연결 상태 박스 */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{isOllamaConnected
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 로컬 엔진 확인 필요'}
</Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
{isOllamaConnected
? `설치된 모델: ${installedModels.length}개 | 활성 모델: ${activeModel}`
: 'Ollama가 실행되어 있지 않거나 설치되지 않았습니다.'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checkingOllama ? 'animate-spin' : ''} />}
onClick={checkOllama}
disabled={checkingOllama}
sx={{ fontSize: '11px' }}
>
</Button>
{!isOllamaConnected && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={handleStartOllama}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
>
Ollama
</Button>
)}
</Box>
</Paper>
{ollamaMsg && (
<Alert severity={isOllamaConnected ? 'success' : 'info'} sx={{ py: 0.5 }}>
{ollamaMsg}
</Alert>
)}
{/* Ollama 미설치 시 가이드 */}
{!isOllamaConnected && (
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
Ollama가 :
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
1. Ollama를 .<br />
2. '연결 재확인' D3RO Voice가 .
</Typography>
<Button
variant="outlined"
size="small"
endIcon={<ExternalLink size={14} />}
onClick={() => window.electronAPI.system.openExternal({ url: 'https://ollama.com/download' })}
sx={{ fontFamily: d3roFontMono, fontSize: '11px', borderColor: d3roPalette.accent.main, color: d3roPalette.accent.light }}
>
ollama.com/download
</Button>
</Box>
)}
{/* 추천 모델 설치 섹션 */}
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
AI (Gemma 2 2B)
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
, , . (크기: 1.6GB)
</Typography>
{pullingModel ? (
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.small }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ fontSize: '12px', color: d3roPalette.accent.light }}>
{pullProgress.status || '모델 다운로드 중...'}
</Typography>
<Typography sx={{ fontSize: '12px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
{pullProgress.percent}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={pullProgress.percent} sx={{ height: 6, borderRadius: 3 }} />
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
startIcon={<Download size={14} />}
onClick={() => handlePullModel('gemma2:2b')}
disabled={!isOllamaConnected}
sx={{
fontWeight: 700,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{hasInstalledLlm ? 'Gemma 2 (2B) 추가 다운로드' : 'Gemma 2 (2B) 1-클릭 다운로드'}
</Button>
{hasInstalledLlm && (
<Typography sx={{ fontSize: '12px', color: d3roPalette.tag.green, display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={15} /> AI .
</Typography>
)}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
<Button variant="text" size="small" onClick={() => setPhase('select_mode')}>
</Button>
<Button
variant="contained"
onClick={handleCompleteLocalSetup}
disabled={!isOllamaConnected}
sx={{ fontWeight: 700 }}
>
</Button>
</Box>
</Box>
)}
{isFailed && (
<Typography sx={{ ...typoSx('body'), color: colorDanger, mt: 2 }}>
{t('onboarding.failed', { message: errorMsg })}
</Typography>
{/* ── 3. 온라인 계정 인증 ── */}
{phase === 'online_auth' && (
<Box component="form" onSubmit={handleAuthSubmit}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
D3RO .
</Typography>
<Tabs
value={authTab}
onChange={(_, v) => setAuthTab(v)}
sx={{ mb: 2, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}
>
<Tab label="로그인" value="login" />
<Tab label="회원가입" value="register" />
</Tabs>
{authError && (
<Typography sx={{ color: d3roPalette.tag.red, mb: 2, ...typoSx('small') }}>
{authError}
</Typography>
)}
<TextField
fullWidth
label="이메일 주소"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="비밀번호"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
sx={{ mb: 3 }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="text" onClick={() => setPhase('select_mode')}>
</Button>
<Button type="submit" variant="contained" disabled={authLoading}>
{authLoading ? '처리 중...' : authTab === 'login' ? '로그인 완료' : '회원가입 완료'}
</Button>
</Box>
</Box>
)}
{/* ── 4. 설정 완료 ── */}
{phase === 'success' && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<CheckCircle2 size={48} style={{ color: d3roPalette.tag.green, margin: '0 auto 16px' }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.tag.green, mb: 1, fontSize: '18px' }}>
D3RO Voice !
</Typography>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, maxWidth: 440, mx: 'auto' }}>
<b></b> STT Ollama .
</Typography>
</Box>
)}
{phase === 'failed' && (
<Box sx={{ py: 2 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.tag.red, mb: 2 }}>
: {errorMsg}
</Typography>
<Button variant="contained" onClick={() => setPhase('select_mode')}>
</Button>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
{phase === 'prompt' && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.cancel')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.download')}
</Button>
</>
)}
{isDownloading && (
<Button disabled sx={{ color: d3roPalette.text.disabled }}>
{t('onboarding.downloading')}
{phase === 'success' && (
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 700 }}>
D3RO Voice
</Button>
)}
{isSuccess && (
<Button
onClick={handleClose}
variant="contained"
sx={{ backgroundColor: colorSuccess }}
>
{t('onboarding.close')}
</Button>
)}
{isFailed && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.close')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.retry')}
</Button>
</>
)}
</DialogActions>
</DialogActions>
)}
</Dialog>
)
}

View file

@ -0,0 +1,597 @@
// src/renderer/components/STTTab.tsx
// STT 서비스 선택 및 다중 AI 공급자(OpenAI, Groq, Deepgram, AssemblyAI, Google 등) 연동 탭
import { useState, useEffect, useCallback } from 'react'
import {
Box,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
TextField,
Button,
Switch,
FormControlLabel,
Divider,
Paper,
Stack,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
IconButton,
InputAdornment,
Alert,
} from '@mui/material'
import {
ChevronDown,
Key,
HelpCircle,
CheckCircle2,
AlertCircle,
Eye,
EyeOff,
Cpu,
Cloud,
Zap,
Sparkles,
RefreshCw,
HardDriveDownload,
} from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
AppConfig,
STTProviderType,
STTProviderInfo,
STTProviderConfig,
STTModel,
} from '@d3ro/core/types'
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
interface STTTabProps {
config: Partial<AppConfig>
updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void
}
export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElement {
const { t } = useI18n()
const [providers, setProviders] = useState<STTProviderInfo[]>([])
const [activeProvider, setActiveProvider] = useState<STTProviderType>(config.sttProvider || 'local')
const [providerConfig, setProviderConfig] = useState<STTProviderConfig>({})
const [showApiKey, setShowApiKey] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
// Local models
const [localModels, setLocalModels] = useState<STTModel[]>([])
const [downloadingModelId, setDownloadingModelId] = useState<string | null>(null)
const [downloadPercent, setDownloadPercent] = useState<number>(0)
// Test connection state
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null)
// Load providers and local models
useEffect(() => {
window.electronAPI.stt.getProviders().then((res) => {
if (res.success && res.data) setProviders(res.data)
})
window.electronAPI.stt.getActiveProvider().then((res) => {
if (res.success && res.data) setActiveProvider(res.data)
})
window.electronAPI.stt.getModels().then((res) => {
if (res.success && res.data) setLocalModels(res.data)
})
const unsubDownload = window.electronAPI.stt.onDownloadProgress((e) => {
setDownloadingModelId(e.modelId)
setDownloadPercent(e.percent)
if (e.percent >= 100) {
setDownloadingModelId(null)
window.electronAPI.stt.getModels().then((res) => {
if (res.success && res.data) setLocalModels(res.data)
})
}
})
return () => {
unsubDownload()
}
}, [])
// Load specific provider config when activeProvider changes
useEffect(() => {
if (!activeProvider) return
window.electronAPI.stt.getProviderConfig({ provider: activeProvider }).then((res) => {
if (res.success && res.data) {
setProviderConfig(res.data)
}
})
setTestResult(null)
}, [activeProvider])
const handleProviderChange = useCallback(
(provider: STTProviderType) => {
setActiveProvider(provider)
updateConfig('sttProvider', provider)
window.electronAPI.stt.setProvider({ provider })
},
[updateConfig]
)
const handleProviderConfigFieldChange = useCallback(
(field: keyof STTProviderConfig, value: unknown) => {
const updated = { ...providerConfig, [field]: value }
setProviderConfig(updated)
window.electronAPI.stt.setProviderConfig({
provider: activeProvider,
config: updated,
})
},
[activeProvider, providerConfig]
)
const handleTestConnection = useCallback(async () => {
setTesting(true)
setTestResult(null)
try {
const res = await window.electronAPI.stt.testConnection({
provider: activeProvider,
apiKey: providerConfig.apiKey,
baseUrl: providerConfig.baseUrl,
modelId: providerConfig.modelId,
})
if (res.success && res.data) {
setTestResult(res.data)
}
} finally {
setTesting(false)
}
}, [activeProvider, providerConfig])
const handleDownloadModel = useCallback(async (modelId: string) => {
setDownloadingModelId(modelId)
setDownloadPercent(0)
await window.electronAPI.stt.downloadModel({ modelId })
}, [])
const currentProviderInfo = providers.find((p) => p.id === activeProvider)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── 1. 공급자 선택 헤더 ── */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
STT
</Typography>
<Button
size="small"
variant="text"
startIcon={<HelpCircle size={14} />}
onClick={() => setGuideOpen(true)}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
color: d3roPalette.accent.main,
py: 0.2,
px: 1,
}}
>
OpenAI & Codex OAuth
</Button>
</Box>
{/* ── 공급자 카드 그리드 ── */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 1.2,
}}
>
{providers.map((p) => {
const isSelected = p.id === activeProvider
return (
<Paper
key={p.id}
elevation={0}
onClick={() => handleProviderChange(p.id)}
sx={{
p: 1.5,
cursor: 'pointer',
borderRadius: d3roRadius.sm,
bgcolor: isSelected ? d3roPalette.bg.elevated : d3roPalette.bg.inset,
border: isSelected ? `1.5px solid ${d3roPalette.accent.main}` : `1px solid ${d3roPalette.border.subtle}`,
boxShadow: isSelected ? d3roShadow.elevated : 'none',
transition: 'all 120ms ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
bgcolor: d3roPalette.bg.elevated,
borderColor: isSelected ? d3roPalette.accent.main : d3roPalette.border.default,
},
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
{p.isCloud ? (
p.id === 'groq' ? (
<Zap size={15} color={d3roPalette.tag.green} />
) : p.id === 'openai' ? (
<Sparkles size={15} color={d3roPalette.accent.main} />
) : (
<Cloud size={15} color={d3roPalette.text.secondary} />
)
) : (
<Cpu size={15} color={d3roPalette.tag.blue} />
)}
{isSelected && (
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
)}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '12px', mb: 0.2 }}>
{p.name}
</Typography>
</Box>
<Chip
label={p.badge}
size="small"
sx={{
mt: 1,
height: 18,
fontSize: '9px',
fontWeight: 700,
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
}}
/>
</Paper>
)
})}
</Box>
</Box>
{/* ── 2. 선택된 공급자 빠른 설정 (Simple Mode) ── */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.md,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
{currentProviderInfo?.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
{currentProviderInfo?.description}
</Typography>
</Box>
<Chip
label={currentProviderInfo?.isCloud ? '클라우드 API' : '로컬 오프라인'}
size="small"
sx={{
height: 20,
fontSize: '10px',
fontWeight: 700,
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
}}
/>
</Box>
{/* Local Whisper 설정 */}
{activeProvider === 'local' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl size="small" fullWidth>
<InputLabel>{t('settings.whisperModel')}</InputLabel>
<Select
label={t('settings.whisperModel')}
value={config.sttModelId ?? 'large-v3-turbo'}
onChange={(e) => {
updateConfig('sttModelId', e.target.value)
window.electronAPI.stt.setModel({ modelId: e.target.value })
}}
>
{localModels.map((m) => (
<MenuItem key={m.id} value={m.id}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
<span>{m.name} ({Math.round(m.sizeBytes / 1_000_000)} MB)</span>
{m.downloaded ? (
<Chip label="설치됨" size="small" sx={{ height: 18, fontSize: '10px', bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
) : (
<Chip label="미다운로드" size="small" sx={{ height: 18, fontSize: '10px', bgcolor: d3roPalette.tag.orangeBg, color: d3roPalette.tag.orange }} />
)}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{/* 모델 다운로드 진행 바 또는 다운로드 버튼 */}
{(() => {
const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo'))
if (selected && !selected.downloaded) {
const isDownloading = downloadingModelId === selected.id
return (
<Paper
elevation={0}
sx={{
p: 1.5,
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.sm,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{selected.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
크기: {Math.round(selected.sizeBytes / 1_000_000)} MB
</Typography>
</Box>
<Button
size="small"
variant="contained"
disabled={isDownloading}
startIcon={isDownloading ? <CircularProgress size={14} /> : <HardDriveDownload size={14} />}
onClick={() => handleDownloadModel(selected.id)}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{isDownloading ? `다운로드 중 (${downloadPercent}%)` : '모델 다운로드'}
</Button>
</Paper>
)
}
return null
})()}
</Box>
) : (
/* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label={`${currentProviderInfo?.name} API Key`}
type={showApiKey ? 'text' : 'password'}
value={providerConfig.apiKey ?? ''}
onChange={(e) => handleProviderConfigFieldChange('apiKey', e.target.value)}
placeholder={
activeProvider === 'openai'
? 'sk-proj-...'
: activeProvider === 'groq'
? 'gsk_...'
: 'API Key를 입력하세요'
}
fullWidth
size="small"
InputProps={{
startAdornment: <Key size={16} style={{ color: d3roPalette.text.inactive, marginRight: 8 }} />,
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowApiKey((v) => !v)} edge="end">
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleTestConnection}
disabled={testing}
startIcon={testing ? <CircularProgress size={14} /> : <RefreshCw size={14} />}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
minWidth: 105,
height: 40,
whiteSpace: 'nowrap',
}}
>
{testing ? '테스트 중...' : '연결 테스트'}
</Button>
</Box>
{/* Test result feedback banner */}
{testResult && (
<Alert
severity={testResult.success ? 'success' : 'error'}
icon={testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
sx={{
py: 0.5,
px: 1.5,
fontSize: '12px',
bgcolor: testResult.success ? d3roPalette.tag.greenBg : d3roPalette.tag.redBg,
color: testResult.success ? d3roPalette.tag.green : d3roPalette.tag.red,
border: `1px solid ${testResult.success ? d3roPalette.tag.green : d3roPalette.tag.red}`,
}}
>
{testResult.message}
</Alert>
)}
</Box>
)}
</Paper>
{/* ── 3. 전문가 상세 설정 (Accordion - Collapsed by default) ── */}
<Accordion
elevation={0}
sx={{
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: `${d3roRadius.sm} !important`,
'&:before': { display: 'none' },
}}
>
<AccordionSummary
expandIcon={<ChevronDown size={18} color={d3roPalette.text.inactive} />}
sx={{
minHeight: 40,
py: 0.5,
'& .MuiAccordionSummary-content': { my: 0.5 },
}}
>
<Typography
variant="subtitle2"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
color: d3roPalette.text.secondary,
letterSpacing: '0.5px',
}}
>
(, , )
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0, pb: 2, px: 2 }}>
<Stack spacing={2}>
<Divider sx={{ borderColor: d3roPalette.border.subtle, mb: 1 }} />
{/* Custom Model ID */}
<TextField
label="사용할 모델 ID (Model ID)"
size="small"
value={providerConfig.modelId ?? currentProviderInfo?.defaultModel ?? ''}
onChange={(e) => handleProviderConfigFieldChange('modelId', e.target.value)}
helperText={`기본값: ${currentProviderInfo?.defaultModel || '자동'}`}
fullWidth
/>
{/* Custom Base URL (for proxies or custom servers) */}
{activeProvider !== 'local' && (
<TextField
label="API 엔드포인트 URL (Base URL)"
size="small"
value={providerConfig.baseUrl ?? currentProviderInfo?.defaultBaseUrl ?? ''}
onChange={(e) => handleProviderConfigFieldChange('baseUrl', e.target.value)}
placeholder={currentProviderInfo?.defaultBaseUrl}
helperText="프록시 또는 사내 프라이빗 서버를 사용하는 경우 변경하세요."
fullWidth
/>
)}
{/* Fallback to local Whisper toggle */}
<FormControlLabel
control={
<Switch
checked={config.sttFallbackToLocal ?? true}
onChange={(e) => updateConfig('sttFallbackToLocal', e.target.checked)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.green,
},
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontSize: d3roTypo.small.size, fontWeight: 600 }}>
Whisper (Auto Fallback)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
STT ( , , ) Whisper로 .
</Typography>
</Box>
}
/>
</Stack>
</AccordionDetails>
</Accordion>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* ── 4. 언어 및 화자 구분 공통 설정 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControl size="small" fullWidth>
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
<Select
label={t('settings.sttLanguage')}
value={config.sttLanguage ?? 'auto'}
onChange={(e) => {
updateConfig('sttLanguage', e.target.value)
window.electronAPI.stt.setLanguage({ language: e.target.value })
}}
>
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
</Select>
</FormControl>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.diarization')}
</Typography>
<TextField
label={t('settings.hfToken')}
type="password"
value={(config as Record<string, unknown>)['hfToken'] as string ?? ''}
onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)}
fullWidth
size="small"
helperText={t('settings.hfTokenHint')}
/>
<FormControlLabel
control={
<Switch
checked={(config as Record<string, unknown>)['diarizationEnabled'] as boolean ?? false}
onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.purple,
},
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontSize: d3roTypo.small.size }}>{t('settings.diarization')}</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{t('settings.diarizationHint')}
</Typography>
</Box>
}
/>
</Box>
{/* Codex OAuth & API Key Setup Guide Modal */}
<CodexOAuthGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
</Box>
)
}

View file

@ -24,19 +24,22 @@ import {
Stack,
Paper,
} from '@mui/material'
import { X, Keyboard, Pencil, Mic, Lock, Cloud } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, Sparkles, CheckCircle2 } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
import { formatHotkeyLabel, formatHotkeySegments } from '../utils/format-hotkey'
import { LicenseTab } from './LicenseTab'
import { CloudSyncSection } from './CloudSyncSection'
import { STTTab } from './STTTab'
import { useI18n, LOCALE_META } from '@d3ro/i18n'
import type { Locale } from '@d3ro/i18n'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice } from '@d3ro/core/types'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types'
interface SettingsModalProps {
open: boolean
initialTab?: number
onClose: () => void
}
@ -181,12 +184,18 @@ function VoiceModeCard({
)
}
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
const { t, locale, setLocale } = useI18n()
const [activeTab, setActiveTab] = useState(0)
const [activeTab, setActiveTab] = useState(initialTab)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (open && initialTab !== undefined) {
setActiveTab(initialTab)
}
}, [open, initialTab])
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
@ -199,8 +208,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
// LLM 모델 목록
const [llmModels, setLlmModels] = useState<Array<{ id: string; name: string; parameterSize: string }>>([])
// 오디오 디바이스
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
@ -208,10 +215,32 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
const [micTesting, setMicTesting] = useState(false)
const [micLevel, setMicLevel] = useState(0)
// Ollama 상태
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
const [ollamaModels, setOllamaModels] = useState<LLMModel[]>([])
const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaFeedback, setOllamaFeedback] = useState<string | null>(null)
const refreshOllama = useCallback(async () => {
setCheckingOllama(true)
try {
const res = await window.electronAPI.llm.checkConnection()
if (res.success && res.data) {
setOllamaModels(res.data.models)
}
const st = await window.electronAPI.llm.getStatus()
if (st.success) setOllamaStatus(st.data)
} finally {
setCheckingOllama(false)
}
}, [])
// 설정 로드
useEffect(() => {
if (!open) return
setLoading(true)
refreshOllama()
Promise.all([
window.electronAPI.config.getAll(),
@ -240,11 +269,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
if (selectedResult.success && selectedResult.data) setSelectedDeviceId(selectedResult.data)
})
// LLM 모델 목록 로드
window.electronAPI.llm.getModels().then((resp) => {
if (resp.success) setLlmModels(resp.data)
})
}, [open])
}, [open, refreshOllama])
const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
setConfig((prev) => ({ ...prev, [key]: value }))
@ -618,185 +643,189 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
{/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>{t('settings.whisperModel')}</InputLabel>
<Select
label={t('settings.whisperModel')}
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">{t('settings.model.tiny')}</MenuItem>
<MenuItem value="base">{t('settings.model.base')}</MenuItem>
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
<MenuItem value="large-v3-turbo">{t('settings.model.largeTurbo')}</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
<Select
label={t('settings.sttLanguage')}
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
</Select>
</FormControl>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.diarization')}
</Typography>
<TextField
label={t('settings.hfToken')}
type="password"
value={(config as Record<string, unknown>)['hfToken'] as string ?? ''}
onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)}
fullWidth
size="small"
helperText={t('settings.hfTokenHint')}
/>
<FormControlLabel
control={
<Switch
checked={(config as Record<string, unknown>)['diarizationEnabled'] as boolean ?? false}
onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.purple,
},
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontSize: d3roTypo.small.size }}>{t('settings.diarization')}</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{t('settings.diarizationHint')}
</Typography>
</Box>
}
/>
</Box>
<STTTab config={config} updateConfig={updateConfig} />
</TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.llmBackend')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* LLM 백엔드 선택 */}
<Box>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px', mb: 1, display: 'block' }}>
AI
</Typography>
<FormControl size="small" fullWidth>
<Select
value={config.llmBackend ?? 'local'}
onChange={(e) => {
const val = e.target.value as 'local' | 'online'
updateConfig('llmBackend', val)
updateConfig('appUsageMode', val)
}}
>
<MenuItem value="local">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={16} color={d3roPalette.tag.green} />
<span> Ollama (100% / )</span>
</Box>
</MenuItem>
<MenuItem value="online">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={16} color={d3roPalette.tag.blue} />
<span>D3RO API</span>
</Box>
</MenuItem>
</Select>
</FormControl>
</Box>
<FormControl size="small">
<InputLabel>{t('settings.llmBackend')}</InputLabel>
<Select
label={t('settings.llmBackend')}
value={config.llmBackend ?? 'local'}
onChange={(e) => {
const next = e.target.value
updateConfig('llmBackend', next)
if (next === 'premium') {
// Premium 선택 시 항상 라이선스 모달 — 현재 티어/쿼터/업그레이드 안내
window.dispatchEvent(new Event('d3ro:open-license-modal'))
}
}}
>
<MenuItem value="local">{t('settings.backend.local')}</MenuItem>
<MenuItem value="premium">{t('settings.backend.premium')}</MenuItem>
</Select>
</FormControl>
{/* 로컬 Ollama 설정 */}
{config.llmBackend !== 'online' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Ollama 연결 상태 카드 */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaStatus?.connectionState === 'connected'
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
</Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
: {ollamaModels.length} | : {config.llmModelId ?? 'gemma2:2b'}
</Typography>
</Box>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{config.llmBackend === 'premium'
? t('settings.backend.premiumHint')
: t('settings.backend.localHint')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checkingOllama ? 'animate-spin' : ''} />}
onClick={refreshOllama}
disabled={checkingOllama}
sx={{ fontSize: '11px', fontFamily: d3roFontMono }}
>
</Button>
{ollamaStatus?.connectionState !== 'connected' && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={async () => {
setStartingOllama(true)
setOllamaFeedback(null)
try {
const res = await window.electronAPI.llm.startServer()
if (res.success) {
if (res.data === 'running') setOllamaFeedback('Ollama가 이미 실행 중입니다.')
else if (res.data === 'starting') {
setOllamaFeedback('Ollama 서버를 시작했습니다.')
setTimeout(() => refreshOllama(), 2000)
} else if (res.data === 'not-installed') {
setOllamaFeedback('Ollama가 설치되어 있지 않습니다. 가이드를 열어 설치해 주세요.')
}
}
} finally {
setStartingOllama(false)
}
}}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
>
{startingOllama ? '실행 중' : 'Ollama 실행'}
</Button>
)}
<Button
size="small"
variant="text"
startIcon={<HelpCircle size={13} />}
onClick={() => window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide'))}
sx={{ fontSize: '11px', color: d3roPalette.accent.main }}
>
/
</Button>
</Box>
</Paper>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{ollamaFeedback && (
<Typography sx={{ fontSize: '11px', color: d3roPalette.tag.orange }}>
{ollamaFeedback}
</Typography>
)}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.conversationBackend')}
</Typography>
{/* 서버 URL & 모델 선택 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
size="small"
/>
<FormControl size="small">
<InputLabel>{t('settings.conversationBackend')}</InputLabel>
<Select
label={t('settings.conversationBackend')}
value={config.conversationBackend ?? 'local'}
onChange={(e) => {
const next = e.target.value
updateConfig('conversationBackend', next)
if (next === 'realtime') {
// Realtime은 로그인+구독 필요 — 라이선스 모달로 현재 티어 안내
window.dispatchEvent(new Event('d3ro:open-license-modal'))
}
}}
>
<MenuItem value="local">{t('settings.convBackend.local')}</MenuItem>
<MenuItem value="realtime">{t('settings.convBackend.realtime')}</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{config.conversationBackend === 'realtime'
? t('settings.convBackend.realtimeHint')
: t('settings.convBackend.localHint')}
</Typography>
{config.llmBackend !== 'premium' && (
<>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.ollamaServer')}
</Typography>
<FormControl size="small" fullWidth>
<InputLabel> Ollama </InputLabel>
<Select
label="활성 Ollama 모델"
value={config.llmModelId ?? 'gemma2:2b'}
onChange={(e) => {
const val = e.target.value
updateConfig('llmModelId', val)
window.electronAPI.llm.setModel({ modelId: val })
}}
>
{/* 설치된 모델 목록 */}
{ollamaModels.map((m) => (
<MenuItem key={m.id} value={m.name}>
{m.name} ({Math.round(m.sizeBytes / (1024 * 1024 * 1024) * 10) / 10} GB)
</MenuItem>
))}
{/* 설치된 모델 목록이 없거나 기본 추천 추가 */}
{ollamaModels.length === 0 && (
<MenuItem value="gemma2:2b">gemma2:2b ()</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'llama3.2:3b') && (
<MenuItem value="llama3.2:3b">llama3.2:3b</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'qwen2.5:3b') && (
<MenuItem value="qwen2.5:3b">qwen2.5:3b</MenuItem>
)}
</Select>
</FormControl>
</Box>
</Box>
)}
{/* 온라인 API 설정 */}
{config.llmBackend === 'online' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
label="API 서버 URL"
value={config.onlineApiUrl ?? 'http://localhost:5000'}
onChange={(e) => updateConfig('onlineApiUrl', e.target.value)}
fullWidth
size="small"
/>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{t('settings.ollamaHint')}
: {config.authToken ? `${config.userEmail ?? '로그인됨'} (온라인 API 가용)` : '로그인 필요'}
</Typography>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.llmModel')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.llmModel')}</InputLabel>
<Select
label={t('settings.llmModel')}
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
onChange={(e) => {
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
}}
>
{llmModels.map((model) => (
<MenuItem key={model.id} value={model.id}>
{model.name} ({model.parameterSize})
</MenuItem>
))}
</Select>
</FormControl>
</>
</Box>
)}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />

View file

@ -1,11 +1,10 @@
// src/renderer/components/StatusBar.tsx
// v2 "Midnight Glass" 하단 상태바 — 좌: 브랜드+버전+백엔드 상태, 우: 로컬 시간.
// Ollama 오프라인 넛징 버블 유지.
// v3 "Tactile Liquid & Midnight Glass" Bottom Telemetry Bar
import { useState, useEffect } from 'react'
import { Box, Typography, Fade, IconButton } from '@mui/material'
import { X } from 'lucide-react'
import { Led } from '@d3ro/ui/components/ds'
import { X, ShieldCheck } from 'lucide-react'
import { Led, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { OllamaGuideModal } from './OllamaGuideModal'
import { useI18n } from '@d3ro/i18n'
@ -45,12 +44,18 @@ export function StatusBar(): React.ReactElement {
window.electronAPI.system.getVersion().then((r) => {
if (r.success) setVersion(r.data)
})
// 초기 상태 로드 + 이벤트 구독 (폴링 불필요 — onStatusChanged로 실시간 갱신)
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setLlmStatus(r.data)
})
const unsub = window.electronAPI.llm.onStatusChanged((e) => setLlmStatus(e.status))
return unsub
const handleOpenGuide = () => setGuideOpen(true)
window.addEventListener('d3ro:open-ollama-guide', handleOpenGuide)
return () => {
unsub()
window.removeEventListener('d3ro:open-ollama-guide', handleOpenGuide)
}
}, [])
const connected = llmStatus?.connectionState === 'connected'
@ -77,7 +82,7 @@ export function StatusBar(): React.ReactElement {
py: 0.5,
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.bg.sidebar,
minHeight: 30,
minHeight: 32,
}}
>
{/* 넛징 버블 */}
@ -85,7 +90,7 @@ export function StatusBar(): React.ReactElement {
<Box
sx={{
position: 'absolute',
bottom: 34,
bottom: 36,
left: 8,
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
@ -136,62 +141,57 @@ export function StatusBar(): React.ReactElement {
</Box>
</Fade>
{/* 좌: 브랜드 + 버전 */}
{/* 좌: 브랜드 + 버전 + LLM 상태 */}
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: connected ? 'default' : 'pointer' }}
sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: connected ? 'default' : 'pointer' }}
onClick={() => !connected && setGuideOpen(true)}
>
<Led color={connected ? 'green' : 'red'} size={7} />
<Led color={connected ? 'green' : 'amber'} size={7} pulse={connected} />
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.meta.size,
fontWeight: 600,
color: connected ? d3roPalette.text.secondary : d3roPalette.tag.red,
color: connected ? d3roPalette.text.secondary : d3roPalette.tag.orange,
'&:hover': connected ? {} : { textDecoration: 'underline' },
}}
>
D3RO Voice
</Typography>
{version && (
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel }}>
v{version}
</Typography>
)}
</Box>
{llmStatus?.activeModel && (
<Typography sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.meta.size,
color: d3roPalette.text.dimLabel,
}}>
<TactileBadge mono tone="mono">
{llmStatus.activeModel}
</Typography>
</TactileBadge>
)}
{/* 자막 상태 표시 */}
{captionActive && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led color="red" pulse size={6} />
<Typography sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.meta.size,
fontWeight: 600,
color: d3roPalette.tag.red,
}}>
{t('dashboard.caption')}
</Typography>
</Box>
<TactileBadge ledColor="red" ledPulse tone="error">
LIVE CAPTION
</TactileBadge>
)}
<Box sx={{ flex: 1 }} />
{/* 중/우: 100% 온디바이스 프라이버시 인디케이터 */}
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center', gap: 0.75 }}>
<ShieldCheck size={13} style={{ color: d3roPalette.tag.green }} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10.5px', color: d3roPalette.text.dimLabel, letterSpacing: '0.04em' }}>
100% LOCAL PRIVACY · ZERO CLOUD SYNC
</Typography>
</Box>
<Box sx={{ flex: 1 }} />
{/* 우: 로컬 시간 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
{t('status.localTime')}
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary, fontVariantNumeric: 'tabular-nums' }}>
{clock}
</Typography>

View file

@ -1,7 +1,7 @@
// src/renderer/components/TemplateSection.tsx
// Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 내 섹션)
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip } from '@mui/material'
import { Plus, Trash2, Pencil, Play } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
@ -22,6 +22,10 @@ export function TemplateSection(): React.ReactElement {
const [formFields, setFormFields] = useState<TemplateField[]>([])
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
const [saving, setSaving] = useState(false)
const isSavingRef = useRef(false)
const isDeletingRef = useRef(false)
const loadData = useCallback(async () => {
setLoading(true)
const result = await window.electronAPI.dictationTemplate.getAll()
@ -45,6 +49,7 @@ export function TemplateSection(): React.ReactElement {
}, [loadData])
const openCreate = () => {
if (dialogOpen) return
setEditId(null)
setFormName('')
setFormDesc('')
@ -54,6 +59,7 @@ export function TemplateSection(): React.ReactElement {
}
const openEdit = (template: DictationTemplate) => {
if (dialogOpen) return
setEditId(template.id)
setFormName(template.name)
setFormDesc(template.description)
@ -63,29 +69,43 @@ export function TemplateSection(): React.ReactElement {
}
const handleSave = async () => {
if (editId) {
await window.electronAPI.dictationTemplate.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
fields: formFields,
outputFormat: formOutput,
})
} else {
await window.electronAPI.dictationTemplate.create({
name: formName.trim(),
description: formDesc.trim(),
fields: formFields,
outputFormat: formOutput,
})
if (!formName.trim() || formFields.length === 0 || saving || isSavingRef.current) return
isSavingRef.current = true
setSaving(true)
try {
if (editId) {
await window.electronAPI.dictationTemplate.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
fields: formFields,
outputFormat: formOutput,
})
} else {
await window.electronAPI.dictationTemplate.create({
name: formName.trim(),
description: formDesc.trim(),
fields: formFields,
outputFormat: formOutput,
})
}
setDialogOpen(false)
loadData()
} finally {
setSaving(false)
isSavingRef.current = false
}
setDialogOpen(false)
loadData()
}
const handleDelete = async (id: string) => {
await window.electronAPI.dictationTemplate.delete({ id })
loadData()
if (isDeletingRef.current) return
isDeletingRef.current = true
try {
await window.electronAPI.dictationTemplate.delete({ id })
loadData()
} finally {
isDeletingRef.current = false
}
}
const handleStartSession = async (templateId: string) => {
@ -281,8 +301,8 @@ export function TemplateSection(): React.ReactElement {
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0}>
{t('common.save')}
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0 || saving}>
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
</Button>
</DialogActions>
</Dialog>

View file

@ -6,7 +6,7 @@
import { useState, useEffect, useCallback } from 'react'
import { Box } from '@mui/material'
import { Minus, Square, Copy, X } from 'lucide-react'
import { d3roPalette } from '@d3ro/ui/theme'
import { d3roPalette, d3roRadius } from '@d3ro/ui/theme'
const BAR_HEIGHT = 40
@ -28,24 +28,30 @@ export function TitleBar(): React.ReactElement {
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
position: 'relative',
height: BAR_HEIGHT,
zIndex: (theme) => theme.zIndex.drawer + 2,
width: '100%',
bgcolor: d3roPalette.bg.sidebar,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'flex-start',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
WebkitAppRegion: 'drag',
pointerEvents: 'auto',
userSelect: 'none',
flexShrink: 0,
zIndex: (theme) => theme.zIndex.drawer + 2,
}}
>
{/* Drag Region Placeholder */}
<Box sx={{ flex: 1, height: '100%', WebkitAppRegion: 'drag' }} />
{/* Window Controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
m: '6px 8px 0 0',
gap: '2px',
}}
>

View file

@ -0,0 +1,276 @@
// apps/desktop/src/renderer/components/ads/AdBanner.tsx
// Dynamic Multi-Network Dock Sponsor Banner connected to 10+ Ad Mediation Engine
import React, { useState, useEffect, useCallback } from 'react'
import { Box, Typography, Button } from '@mui/material'
import { Sparkles, ExternalLink } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import type { LicenseTier, AdCreativePayload } from '@d3ro/core/types'
interface AdBannerProps {
tier: LicenseTier
onOpenUpgradeModal: () => void
}
const DEFAULT_CREATIVE: AdCreativePayload = {
id: 'cursor_default',
networkId: 'direct_sponsor',
networkName: 'Direct Partner',
title: 'Cursor AI — Next-Gen AI Code Editor',
description: 'Build software with intelligent voice agents & lightning-speed code search.',
ctaText: 'Learn More',
clickUrl: 'https://cursor.com',
sponsorTag: 'Sponsor',
advertiserName: 'Cursor AI',
bidEcpm: 15.5,
format: 'banner_dock',
}
export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.ReactElement | null {
const [creative, setCreative] = useState<AdCreativePayload>(DEFAULT_CREATIVE)
// Fetch highest bidding ad creative via mediation auction
const fetchAdAuction = useCallback(async () => {
if (tier !== 'free') return
try {
if (window.electronAPI?.ads?.requestAuction) {
const res = await window.electronAPI.ads.requestAuction({
placement: 'bottom_dock_banner',
format: 'banner_dock',
floorEcpm: 2.0,
})
if (res.success && res.data?.winner) {
setCreative(res.data.winner)
// Record impression
window.electronAPI.ads.recordImpression({
adId: res.data.winner.id,
format: 'banner_dock',
network: res.data.winner.networkId,
networkName: res.data.winner.networkName,
earnedEcpm: res.data.winningBidEcpm,
})
}
}
} catch {
// fallback to default creative
}
}, [tier])
useEffect(() => {
fetchAdAuction()
// Auto-refresh ad creative every 45s for free tier
const interval = setInterval(fetchAdAuction, 45000)
return () => clearInterval(interval)
}, [fetchAdAuction])
// Pro, Pro+, Team, Enterprise users are 100% Ad-Free
if (tier !== 'free') {
return null
}
const handleSponsorClick = () => {
if (window.electronAPI?.ads?.recordClick) {
window.electronAPI.ads.recordClick({ adId: creative.id, networkId: creative.networkId as string })
}
window.open(creative.clickUrl, '_blank')
}
return (
<Box
sx={{
px: 2,
py: 1.25,
borderRadius: d3roRadius.inner,
bgcolor: 'rgba(17, 26, 48, 0.85)',
backdropFilter: 'blur(16px)',
border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.card,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
transition: 'all 0.25s ease',
'&:hover': {
borderColor: d3roPalette.accent.dim,
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
{/* Network-Specific Authentic Test Ad Badge / Watermark */}
<Box
sx={{
width: 34,
height: 34,
borderRadius: '8px',
bgcolor:
creative.networkId === 'google_ad_manager'
? 'rgba(234, 179, 8, 0.15)'
: creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.15)'
: creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.15)'
: 'rgba(59, 130, 246, 0.15)',
border: `1px solid ${
creative.networkId === 'google_ad_manager'
? 'rgba(234, 179, 8, 0.4)'
: creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.4)'
: creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.4)'
: d3roPalette.accent.dim
}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '11px',
fontWeight: 800,
fontFamily: d3roFontMono,
color:
creative.networkId === 'google_ad_manager'
? '#facc15'
: creative.networkId === 'ethical_ads'
? '#4ade80'
: creative.networkId === 'carbon_ads'
? '#fb923c'
: d3roPalette.accent.light,
}}
>
{creative.networkId === 'google_ad_manager' ? (
'GAM'
) : creative.networkId === 'ethical_ads' ? (
'ETH'
) : creative.networkId === 'carbon_ads' ? (
'CRB'
) : creative.networkId === 'playwire' ? (
'PLY'
) : creative.networkId === 'applovin_max' ? (
'MAX'
) : (
<Sparkles size={16} color={d3roPalette.accent.light} />
)}
</Box>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Google / Network Official Test Ad Ribbon */}
{creative.networkId === 'google_ad_manager' && (
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 900,
color: '#000',
bgcolor: '#facc15',
px: 0.6,
py: 0.1,
borderRadius: '3px',
letterSpacing: '0.5px',
}}
>
TEST AD
</Typography>
)}
<Typography
noWrap
sx={{
fontFamily: d3roFontSans,
fontSize: '12px',
fontWeight: 700,
color: d3roPalette.text.primary,
}}
>
{creative.title}
</Typography>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 700,
color:
creative.networkId === 'ethical_ads'
? '#4ade80'
: creative.networkId === 'carbon_ads'
? '#fb923c'
: d3roPalette.accent.light,
bgcolor:
creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.12)'
: creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.12)'
: 'rgba(59, 130, 246, 0.15)',
border: `1px solid ${
creative.networkId === 'ethical_ads'
? 'rgba(34, 197, 94, 0.3)'
: creative.networkId === 'carbon_ads'
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(59, 130, 246, 0.3)'
}`,
px: 0.8,
py: 0.2,
borderRadius: '4px',
textTransform: 'uppercase',
}}
>
{creative.sponsorTag || 'Sponsor'}
</Typography>
</Box>
<Typography
noWrap
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
color: d3roPalette.text.secondary,
mt: 0.2,
}}
>
{creative.description}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexShrink: 0 }}>
<Button
size="small"
onClick={handleSponsorClick}
sx={{
minWidth: 'auto',
px: 1.5,
py: 0.5,
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 600,
textTransform: 'none',
color: d3roPalette.accent.light,
bgcolor: 'rgba(59, 130, 246, 0.12)',
border: `1px solid ${d3roPalette.accent.dim}`,
borderRadius: '8px',
'&:hover': {
bgcolor: 'rgba(59, 130, 246, 0.22)',
},
}}
>
<ExternalLink size={12} style={{ marginRight: 4 }} />
{creative.ctaText || 'Learn More'}
</Button>
<Typography
onClick={onOpenUpgradeModal}
sx={{
fontFamily: d3roFontSans,
fontSize: '10px',
color: d3roPalette.text.dimLabel,
cursor: 'pointer',
textDecoration: 'underline',
'&:hover': { color: d3roPalette.tag.greenText },
}}
>
Remove ads with Pro
</Typography>
</Box>
</Box>
)
}

View file

@ -0,0 +1,316 @@
// apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx
// Dynamic Rewarded Video Modal with Multi-Network Video Auction (Unity/AppLovin/Playwire/ElevenLabs)
import React, { useState, useEffect, useCallback } from 'react'
import { Modal, Box, Typography, Button, LinearProgress } from '@mui/material'
import { PlayCircle, Sparkles, Clock, X, CheckCircle2 } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import type { AdCreativePayload } from '@d3ro/core/types'
interface RewardedQuotaModalProps {
open: boolean
onClose: () => void
onRewardClaimed: (addedTokens: number) => void
}
const DEFAULT_VIDEO_CREATIVE: AdCreativePayload = {
id: 'elevenlabs_video_default',
networkId: 'direct_sponsor',
networkName: 'ElevenLabs Direct Partner',
title: 'ElevenLabs Voice Synthesizer',
description: 'Human-like AI audio generation & voice cloning for developers and creators.',
ctaText: 'Explore Voice AI',
clickUrl: 'https://elevenlabs.io',
sponsorTag: 'Rewarded Sponsor',
advertiserName: 'ElevenLabs',
bidEcpm: 18.0,
format: 'rewarded_video',
rewardTokens: 50,
durationSeconds: 15,
}
export function RewardedQuotaModal({
open,
onClose,
onRewardClaimed,
}: RewardedQuotaModalProps): React.ReactElement {
const [creative, setCreative] = useState<AdCreativePayload>(DEFAULT_VIDEO_CREATIVE)
const [timeLeft, setTimeLeft] = useState(15)
const [completed, setCompleted] = useState(false)
const fetchRewardedAuction = useCallback(async () => {
try {
if (window.electronAPI?.ads?.requestAuction) {
const res = await window.electronAPI.ads.requestAuction({
placement: 'rewarded_video_quota',
format: 'rewarded_video',
floorEcpm: 4.0,
})
if (res.success && res.data?.winner) {
setCreative(res.data.winner)
setTimeLeft(res.data.winner.durationSeconds || 15)
// Record video impression
window.electronAPI.ads.recordImpression({
adId: res.data.winner.id,
format: 'rewarded_video',
network: res.data.winner.networkId,
networkName: res.data.winner.networkName,
earnedEcpm: res.data.winningBidEcpm,
})
}
}
} catch {
// fallback
}
}, [])
useEffect(() => {
if (!open) {
setTimeLeft(15)
setCompleted(false)
return
}
fetchRewardedAuction()
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval)
setCompleted(true)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [open, fetchRewardedAuction])
const handleClaim = async () => {
let tokens = 50
if (window.electronAPI?.ads?.claimReward) {
const res = await window.electronAPI.ads.claimReward({
adId: creative.id,
networkId: creative.networkId as string,
})
if (res.success && res.data?.tokensAdded) {
tokens = res.data.tokensAdded
}
}
onRewardClaimed(tokens)
onClose()
}
const duration = creative.durationSeconds || 15
const progressPercent = ((duration - timeLeft) / duration) * 100
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 480 },
bgcolor: d3roPalette.bg.modal,
borderRadius: d3roRadius.modal,
border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.modal,
p: 3,
outline: 'none',
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '8px',
bgcolor: 'rgba(56, 189, 248, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PlayCircle size={18} color={d3roPalette.accent.light} />
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 700, fontSize: '15px', color: d3roPalette.text.primary }}>
Sponsor Video Refill +50 Free AI Tokens
</Typography>
</Box>
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
<X size={18} />
</Box>
</Box>
{/* Video Ad Screen with Authentic Unity / AppLovin Test Mode UI */}
<Box
sx={{
height: 220,
borderRadius: d3roRadius.inner,
bgcolor: '#000000',
border: `1px solid ${d3roPalette.glass.hairline}`,
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
textAlign: 'center',
overflow: 'hidden',
}}
>
{/* Unity / AppLovin Official Test Watermark Ribbon */}
<Box
sx={{
position: 'absolute',
top: 10,
left: 10,
display: 'flex',
alignItems: 'center',
gap: 1,
zIndex: 2,
}}
>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 900,
color: '#fff',
bgcolor: '#2563eb',
px: 1,
py: 0.3,
borderRadius: '4px',
letterSpacing: '0.5px',
}}
>
UNITY ADS TEST MODE
</Typography>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
color: 'rgba(255, 255, 255, 0.6)',
}}
>
Placement: Rewarded_Tokens_Refill
</Typography>
</Box>
<Box
sx={{
position: 'absolute',
top: 10,
right: 10,
display: 'flex',
alignItems: 'center',
gap: 0.5,
color: '#fff',
bgcolor: 'rgba(0, 0, 0, 0.6)',
px: 1,
py: 0.3,
borderRadius: '4px',
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
zIndex: 2,
}}
>
<Clock size={12} />
{completed ? 'Reward Ready' : `00:${timeLeft.toString().padStart(2, '0')}`}
</Box>
{/* Test Commercial Creative Content */}
<Box
sx={{
width: 56,
height: 56,
borderRadius: '14px',
bgcolor: 'rgba(37, 99, 235, 0.25)',
border: `1px solid rgba(59, 130, 246, 0.5)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 1.5,
boxShadow: '0 0 20px rgba(37, 99, 235, 0.4)',
}}
>
<Sparkles size={28} color="#60a5fa" />
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 800, fontSize: '15px', color: '#ffffff' }}>
{creative.title}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'rgba(255, 255, 255, 0.75)', mt: 0.5, maxWidth: 360 }}>
{creative.description}
</Typography>
{/* Video Progress Bar at bottom */}
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
<LinearProgress
variant="determinate"
value={progressPercent}
sx={{
height: 5,
bgcolor: 'rgba(255, 255, 255, 0.1)',
'& .MuiLinearProgress-bar': {
bgcolor: completed ? '#22c55e' : '#3b82f6',
},
}}
/>
</Box>
</Box>
{/* Footer Actions */}
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
Reward: <span style={{ color: d3roPalette.tag.greenText, fontWeight: 700 }}>+50 Cloud AI Tokens</span>
</Typography>
{completed ? (
<Button
variant="contained"
onClick={handleClaim}
startIcon={<CheckCircle2 size={16} />}
sx={{
fontFamily: d3roFontSans,
fontWeight: 700,
fontSize: '12px',
textTransform: 'none',
bgcolor: d3roPalette.tag.greenText,
color: '#000',
px: 2.5,
py: 0.8,
borderRadius: '8px',
'&:hover': { bgcolor: '#4ade80' },
}}
>
Claim +50 Free Tokens
</Button>
) : (
<Button
disabled
sx={{
fontFamily: d3roFontSans,
fontSize: '12px',
textTransform: 'none',
color: 'rgba(255, 255, 255, 0.3)',
bgcolor: 'rgba(255, 255, 255, 0.05)',
px: 2,
py: 0.8,
borderRadius: '8px',
}}
>
Watch to Complete ({timeLeft}s)
</Button>
)}
</Box>
</Box>
</Modal>
)
}

View file

@ -1,7 +1,7 @@
// src/renderer/components/meeting/AddDocumentDialog.tsx
// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import {
Dialog,
DialogTitle,
@ -44,18 +44,26 @@ export function AddDocumentDialog({
const [selectedId, setSelectedId] = useState<string | null>(null)
const [customPrompt, setCustomPrompt] = useState('')
const [customTitle, setCustomTitle] = useState('')
const isGeneratingRef = useRef(false)
useEffect(() => {
if (!open || !generating) {
isGeneratingRef.current = false
}
}, [open, generating])
const selectedTemplate = templates.find((tp) => tp.id === selectedId)
const isCustom = selectedTemplate?.templateType === 'custom'
const handleGenerate = useCallback(() => {
if (!selectedId) return
if (!selectedId || generating || isGeneratingRef.current) return
isGeneratingRef.current = true
onGenerate(
selectedId,
isCustom && customPrompt.trim() ? customPrompt.trim() : undefined,
isCustom && customTitle.trim() ? customTitle.trim() : undefined,
)
}, [selectedId, isCustom, customPrompt, customTitle, onGenerate])
}, [selectedId, generating, isCustom, customPrompt, customTitle, onGenerate])
const handleClose = useCallback(() => {
if (!generating) {

View file

@ -7,7 +7,7 @@ import { Send, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { PhysicalButton } from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { MeetingChatMessage } from '@d3ro/core/types'
@ -85,12 +85,14 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
setMessages((prev) => [...prev, msg])
setStreaming(false)
setStreamingContent('')
isSendingChatRef.current = false
})
const unsubError = window.electronAPI.meetingChat.onError((err) => {
setStreaming(false)
setStreamingContent('')
setError(err.message)
isSendingChatRef.current = false
})
return () => {
@ -100,9 +102,12 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
}
}, [sessionId])
const isSendingChatRef = useRef(false)
const handleSend = useCallback(() => {
const text = inputValue.trim()
if (!text || streaming) return
if (!text || streaming || isSendingChatRef.current) return
isSendingChatRef.current = true
const userMsg: MeetingChatMessage = {
role: 'user',
@ -111,8 +116,12 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
}
setMessages((prev) => [...prev, userMsg])
setInputValue('')
setStreaming(true)
window.electronAPI.meetingChat.send({ sessionId, message: text })
inputRef.current?.focus()
setTimeout(() => {
isSendingChatRef.current = false
}, 500)
}, [inputValue, streaming, sessionId])
const handleKeyDown = useCallback(
@ -179,13 +188,15 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
</PhosphorText>
<Tooltip title={t('meeting.chatClear')}>
<IconButton
size="small"
onClick={handleClear}
disabled={messages.length === 0 && !streaming}
>
<Trash2 size={15} style={{ color: d3roPalette.text.inactive }} />
</IconButton>
<span>
<IconButton
size="small"
onClick={handleClear}
disabled={messages.length === 0 && !streaming}
>
<Trash2 size={15} style={{ color: d3roPalette.text.inactive }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title={collapsed ? t('meeting.chatExpand') : t('meeting.chatCollapse')}>

View file

@ -1,7 +1,7 @@
// src/renderer/components/meeting/MeetingDetailTabs.tsx
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
import { useState, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
Box,
Tabs,
@ -9,15 +9,31 @@ import {
IconButton,
TextField,
Tooltip,
Typography,
Checkbox,
} from '@mui/material'
import { ArrowLeft, Plus, Pencil, Check } from 'lucide-react'
import {
ArrowLeft,
Plus,
Pencil,
Check,
Users,
Copy,
Sparkles,
} from 'lucide-react'
import { TranscriptTab } from './TranscriptTab'
import { DocumentTab } from './DocumentTab'
import { AddDocumentDialog } from './AddDocumentDialog'
import { MeetingChatPanel } from './MeetingChatPanel'
import { PhosphorText } from '@d3ro/ui/components/ds'
import {
PhosphorText,
PhysicalButton,
SegmentControl,
TactileBadge,
MetalCard,
} from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
MeetingSessionDetail,
@ -31,15 +47,15 @@ interface MeetingDetailTabsProps {
onBack: () => void
}
type ViewMode = 'split' | 'transcript' | 'doc'
// 세그먼트 파싱: rawTranscript를 줄 단위로 분리
function parseSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): Array<{ id: string; timestamp: number; text: string; edited: boolean }> {
const source = rawTranscript ?? ''
const edited = editedTranscript ?? ''
// [MM:SS] 텍스트 패턴 파싱 시도
const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
const source = editedTranscript || rawTranscript || ''
const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/
return source
.split('\n')
.filter((line) => line.trim())
@ -48,26 +64,43 @@ function parseSegments(
if (m) {
const min = parseInt(m[1], 10)
const sec = parseInt(m[2], 10)
const text = m[3]
// 수정본이 있으면 edited 판단
const editedLine = edited.split('\n')[idx]?.trim() ?? ''
const speaker = m[3] || undefined
const text = m[4]
return {
id: `seg-${idx}`,
timestamp: (min * 60 + sec) * 1000,
text,
edited: Boolean(editedLine) && editedLine !== line.trim(),
edited: false,
speaker,
}
}
// 타임스탬프 없는 라인: 인덱스 기반
return {
id: `seg-${idx}`,
timestamp: idx * 5000,
timestamp: idx * 4000,
text: line.trim(),
edited: false,
}
})
}
// Extract Action items from documents
function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> {
const lines = content.split('\n')
const items: Array<{ id: string; text: string; done: boolean }> = []
let idx = 0
for (const line of lines) {
const checkMatch = /^\s*[-*]\s*\[([ xX])\]\s*(.+)$/.exec(line)
if (checkMatch) {
items.push({
id: `todo-${idx++}`,
done: checkMatch[1].toLowerCase() === 'x',
text: checkMatch[2].trim(),
})
}
}
return items
}
export function MeetingDetailTabs({
detail: initialDetail,
onBack,
@ -76,14 +109,26 @@ export function MeetingDetailTabs({
const [detail, setDetail] = useState<MeetingSessionDetail>(initialDetail)
const [documents, setDocuments] = useState<MeetingDocument[]>((initialDetail.documents ?? []).filter(Boolean))
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
const [tabIndex, setTabIndex] = useState(0)
// Tab key string state (e.g. `doc-${id}`, 'notepad', 'action-items')
const [activeTabKey, setActiveTabKey] = useState<string>(() => {
const docs = (initialDetail.documents ?? []).filter(Boolean)
return docs.length > 0 ? `doc-${docs[0].id}` : 'notepad'
})
const [dialogOpen, setDialogOpen] = useState(false)
const [generating, setGenerating] = useState(false)
const [diarizing, setDiarizing] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
const [viewMode, setViewMode] = useState<ViewMode>('split')
// 세그먼트 파싱 (rawTranscript 기반)
// Granola-style Scratchpad & Action Items State
const [scratchpadNotes, setScratchpadNotes] = useState('')
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
const [enhancingNotes, setEnhancingNotes] = useState(false)
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
const segments = useMemo(
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
[detail.rawTranscript, detail.editedTranscript],
@ -96,6 +141,30 @@ export function MeetingDetailTabs({
})
}, [])
// Sync action items from primary document
useEffect(() => {
const primaryDoc = documents[0]
if (primaryDoc) {
const extracted = extractActionItems(primaryDoc.content)
if (extracted.length > 0) {
setActionItems(extracted)
}
}
}, [documents])
// If documents change, ensure activeTabKey is valid
useEffect(() => {
if (activeTabKey.startsWith('doc-')) {
const targetId = activeTabKey.replace('doc-', '')
const exists = documents.some((d) => d.id === targetId)
if (!exists && documents.length > 0) {
setActiveTabKey(`doc-${documents[0].id}`)
} else if (!exists) {
setActiveTabKey('notepad')
}
}
}, [documents, activeTabKey])
// ── 제목 저장 ──
const handleSaveTitle = useCallback(async () => {
if (!titleDraft.trim()) return
@ -115,7 +184,6 @@ export function MeetingDetailTabs({
segmentId,
text: newText,
})
// 로컬 editedTranscript 갱신 (단순 반영)
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
},
[detail.id],
@ -133,7 +201,7 @@ export function MeetingDetailTabs({
[detail.id],
)
// ── 문서 내용 자동저장 (DocumentTab 내부에 debounce 있음, 이중 래핑 제거) ──
// ── 문서 내용 자동저장 ──
const handleDocContentChange = useCallback(
async (docId: string, content: string) => {
await window.electronAPI.meetingMode.updateDocument({
@ -147,13 +215,22 @@ export function MeetingDetailTabs({
[],
)
const isExportingRef = useRef(false)
const isDeletingDocRef = useRef(false)
// ── 문서 내보내기 ──
const handleExportDocument = useCallback(
async (docId: string, format: MeetingExportFormat) => {
await window.electronAPI.meetingMode.exportDocument({
documentId: docId,
format,
})
if (isExportingRef.current) return
isExportingRef.current = true
try {
await window.electronAPI.meetingMode.exportDocument({
documentId: docId,
format,
})
} finally {
isExportingRef.current = false
}
},
[],
)
@ -161,32 +238,66 @@ export function MeetingDetailTabs({
// ── 문서 삭제 ──
const handleDeleteDocument = useCallback(
async (docId: string) => {
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
setDocuments((prev) => {
const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭
setTabIndex((currentTab) => {
if (currentTab === docTabIndex) return 0
if (currentTab > docTabIndex) return currentTab - 1
return currentTab
if (isDeletingDocRef.current) return
isDeletingDocRef.current = true
try {
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
setDocuments((prev) => {
const remaining = prev.filter((d) => d.id !== docId)
if (remaining.length > 0) {
setActiveTabKey(`doc-${remaining[0].id}`)
} else {
setActiveTabKey('notepad')
}
return remaining
})
return prev.filter((d) => d.id !== docId)
})
} finally {
isDeletingDocRef.current = false
}
},
[],
)
// ── 화자 구분 ──
const handleDiarize = useCallback(async () => {
if (diarizing) return
setDiarizing(true)
const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id })
if (resp.success) {
const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id })
if (updated.success) setDetail(updated.data)
try {
const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id })
if (resp.success) {
const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id })
if (updated.success) setDetail(updated.data)
}
} finally {
setDiarizing(false)
}
setDiarizing(false)
}, [detail.id])
}, [detail.id, diarizing])
// ── 문서 생성 ──
// ── Granola "Jot & Enhance" AI Note Enhancement ──
const handleEnhanceScratchpad = useCallback(async () => {
if (!scratchpadNotes.trim() || enhancingNotes) return
setEnhancingNotes(true)
try {
const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}`
const resp = await window.electronAPI.llm.generate({ prompt })
if (resp.success && resp.data.text) {
const newDocResult = await window.electronAPI.meetingMode.generateDocument({
sessionId: detail.id,
templateId: 'custom',
customTitle: '📝 내 메모 기반 회의록 (AI 증강)',
customPrompt: prompt,
})
if (newDocResult.success) {
setDocuments((prev) => [...prev, newDocResult.data])
setActiveTabKey(`doc-${newDocResult.data.id}`)
}
}
} finally {
setEnhancingNotes(false)
}
}, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes])
// ── 새 문서 생성 다이얼로그 ──
const handleGenerate = useCallback(
async (templateId: string, customPrompt?: string, customTitle?: string) => {
setGenerating(true)
@ -198,155 +309,347 @@ export function MeetingDetailTabs({
})
setGenerating(false)
if (resp.success) {
const newDoc = resp.data
setDocuments((prev) => {
// 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1
setTabIndex(prev.length + 1)
return [...prev, newDoc]
})
setDocuments((prev) => [...prev, resp.data])
setActiveTabKey(`doc-${resp.data.id}`)
setDialogOpen(false)
}
},
[detail.id],
)
// 탭 수: 전사(1) + 문서들 + [+] 버튼(1)
const totalTabCount = 1 + documents.length + 1
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* 헤더 */}
{/* ── 1. Header Toolbar ──────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
justifyContent: 'space-between',
px: 2.5,
py: 1.25,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.bg.sidebar,
flexShrink: 0,
}}
>
<IconButton size="small" onClick={onBack}>
<ArrowLeft size={18} style={{ color: d3roPalette.text.secondary }} />
</IconButton>
{/* Left: Back & Title */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0, flex: 1 }}>
<IconButton size="small" onClick={onBack} sx={{ color: d3roPalette.text.secondary }}>
<ArrowLeft size={18} />
</IconButton>
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
<TextField
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') handleSaveTitle()
}}
sx={{
flex: 1,
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
}}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle}>
<Check size={16} style={{ color: d3roPalette.accent.main }} />
</IconButton>
</Box>
) : (
<>
<PhosphorText variant="label" sx={{ flex: 1 }}>
{detail.title ?? t('meeting.untitled')}
</PhosphorText>
<Tooltip title={t('meeting.editTitle')}>
<IconButton
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, maxWidth: 400 }}>
<TextField
size="small"
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
>
<Pencil size={15} style={{ color: d3roPalette.text.inactive }} />
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') handleSaveTitle()
}}
sx={{
flex: 1,
'& .MuiInputBase-root': { fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 600 },
}}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle} sx={{ color: d3roPalette.accent.light }}>
<Check size={16} />
</IconButton>
</Tooltip>
</>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '15px',
fontWeight: 700,
color: d3roPalette.text.primary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{detail.title || t('meeting.untitled')}
</Typography>
<Tooltip title={t('common.edit')}>
<IconButton size="small" onClick={() => setEditingTitle(true)} sx={{ color: d3roPalette.text.inactive, p: 0.5 }}>
<Pencil size={13} />
</IconButton>
</Tooltip>
</Box>
)}
<TactileBadge mono tone="mono">
{new Date(detail.startedAt || Date.now()).toLocaleDateString()}
</TactileBadge>
</Box>
{/* Right: Viewport Mode Switcher (Split / Transcript / Doc) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<SegmentControl
options={[
{ value: 'split', label: 'Split ◫' },
{ value: 'transcript', label: 'Transcript ▤' },
{ value: 'doc', label: 'Minutes ▥' },
]}
value={viewMode}
onChange={(v) => setViewMode(v as ViewMode)}
size="small"
/>
<PhysicalButton
tone="accent"
size="small"
onClick={() => {
if (!dialogOpen && !generating) setDialogOpen(true)
}}
disabled={dialogOpen || generating}
trailingIcon={<Plus size={13} />}
>
{t('meeting.addDocument')}
</PhysicalButton>
</Box>
</Box>
{/* ── 2. Dual-Canvas Workspace (Split View) ─────────────────── */}
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden' }}>
{/* Left Canvas: Interactive Transcript Timeline */}
{(viewMode === 'split' || viewMode === 'transcript') && (
<Box
sx={{
width: viewMode === 'split' ? '45%' : '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRight: viewMode === 'split' ? `1px solid ${d3roPalette.glass.hairline}` : 'none',
bgcolor: d3roPalette.bg.app,
p: 2,
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, pb: 1, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Users size={16} style={{ color: d3roPalette.accent.light }} />
<PhosphorText variant="heading" sx={{ fontSize: '13px', fontWeight: 700 }}>
SPEAKER TRANSCRIPT TIMELINE
</PhosphorText>
</Box>
<TactileBadge mono tone="mono">
{segments.length} UTTERANCES
</TactileBadge>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
memos={detail.memos ?? []}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
onDiarize={handleDiarize}
diarizing={diarizing}
/>
</Box>
</Box>
)}
{/* Right Canvas: Structured AI Minutes & Granola Notepad */}
{(viewMode === 'split' || viewMode === 'doc') && (
<Box
sx={{
width: viewMode === 'split' ? '55%' : '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
bgcolor: d3roPalette.bg.sidebar,
overflow: 'hidden',
}}
>
{/* Right Sub-Tabs */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
pt: 1,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.bg.card,
}}
>
<Tabs
value={activeTabKey}
onChange={(_, v) => setActiveTabKey(v)}
sx={{
minHeight: 38,
'& .MuiTab-root': {
fontFamily: d3roFontSans,
fontSize: '12px',
fontWeight: 600,
minHeight: 38,
py: 0.5,
px: 1.75,
color: d3roPalette.text.secondary,
'&.Mui-selected': { color: d3roPalette.accent.light },
},
}}
>
{documents.map((doc, idx) => (
<Tab key={doc.id} value={`doc-${doc.id}`} label={doc.title || `Document ${idx + 1}`} />
))}
<Tab value="notepad" label="📝 Granola Notepad" />
<Tab value="action-items" label={`✅ Action Items (${actionItems.length})`} />
</Tabs>
</Box>
{/* Document Content View */}
<Box sx={{ flex: 1, p: 2.5, minHeight: 0, overflow: 'auto' }}>
{activeTabKey.startsWith('doc-') ? (
// AI Document Editor Tab
(() => {
const targetDocId = activeTabKey.replace('doc-', '')
const currentDoc = documents.find((d) => d.id === targetDocId) || documents[0]
return currentDoc ? (
<DocumentTab
document={currentDoc}
onContentChange={(c) => handleDocContentChange(currentDoc.id, c)}
onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)}
onDelete={() => handleDeleteDocument(currentDoc.id)}
/>
) : (
<Box sx={{ py: 8, textAlign: 'center' }}>
<PhosphorText variant="dim"> . .</PhosphorText>
</Box>
)
})()
) : activeTabKey === 'notepad' ? (
// Granola-style Notepad Tab
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
Granola-style Hybrid Notepad
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}>
. AI가 .
</Typography>
</Box>
<PhysicalButton
tone="accent"
size="small"
onClick={handleEnhanceScratchpad}
disabled={!scratchpadNotes.trim() || enhancingNotes}
>
<Sparkles size={14} style={{ marginRight: 4 }} />
{enhancingNotes ? '증강 중...' : '⚡ AI로 메모 증강하기'}
</PhysicalButton>
</Box>
<TextField
multiline
rows={14}
value={scratchpadNotes}
onChange={(e) => setScratchpadNotes(e.target.value)}
placeholder="- 논의된 주요 이슈 요약&#10;- 고객사 피드백 핵심 포인트&#10;- 내가 해야 할 일 체크리스트..."
fullWidth
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontSans,
fontSize: '13px',
lineHeight: 1.7,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.inner,
p: 2,
},
}}
/>
</Box>
) : (
// Interactive Action Items Checklist Tab
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
Interactive Action Items Checklist
</Typography>
<PhysicalButton
tone="glass"
size="small"
onClick={() => {
const formatted = actionItems.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n')
navigator.clipboard.writeText(formatted)
}}
>
<Copy size={13} style={{ marginRight: 4 }} />
Copy for Slack / Notion
</PhysicalButton>
</Box>
{actionItems.length === 0 ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<PhosphorText variant="dim"> .</PhosphorText>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{actionItems.map((item) => (
<MetalCard
key={item.id}
inset
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
p: 1.5,
bgcolor: item.done ? d3roPalette.bg.inset : d3roPalette.bg.card,
opacity: item.done ? 0.6 : 1,
}}
>
<Checkbox
checked={item.done}
onChange={(e) => {
setActionItems((prev) =>
prev.map((a) => (a.id === item.id ? { ...a, done: e.target.checked } : a)),
)
}}
size="small"
sx={{ color: d3roPalette.accent.light, p: 0.5 }}
/>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '13px',
color: d3roPalette.text.primary,
textDecoration: item.done ? 'line-through' : 'none',
flex: 1,
}}
>
{item.text}
</Typography>
</MetalCard>
))}
</Box>
)}
</Box>
)}
</Box>
</Box>
)}
</Box>
{/* 탭 바 */}
<Box sx={{ borderBottom: `1px solid ${d3roPalette.border.subtle}`, flexShrink: 0 }}>
<Tabs
value={tabIndex}
onChange={(_e, v: number) => {
// 마지막 탭([+])은 다이얼로그 열기
if (v === totalTabCount - 1) {
setDialogOpen(true)
return
}
setTabIndex(v)
}}
sx={{
minHeight: 40,
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
minHeight: 40,
py: 0,
color: d3roPalette.text.inactive,
textTransform: 'uppercase',
letterSpacing: '1px',
'&.Mui-selected': { color: d3roPalette.accent.main },
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
}}
>
<Tab label={t('meeting.transcriptTab')} />
{documents.filter(Boolean).map((doc) => (
<Tab key={doc.id} label={doc.title ?? t('meeting.untitled')} />
))}
<Tab
icon={<Plus size={16} />}
label={t('meeting.addDocument')}
iconPosition="start"
sx={{ minWidth: 120 }}
/>
</Tabs>
</Box>
{/* 탭 콘텐츠 */}
<Box sx={{ flex: 1, overflow: 'auto', p: 2, minHeight: 0 }}>
{tabIndex === 0 && (
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
memos={detail.memos}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
onDiarize={handleDiarize}
diarizing={diarizing}
/>
)}
{documents.filter(Boolean).map((doc, idx) => {
if (!doc || tabIndex !== idx + 1) return null
return (
<DocumentTab
key={doc.id}
document={doc}
onContentChange={(content) => handleDocContentChange(doc.id, content)}
onExport={(format) => handleExportDocument(doc.id, format)}
onDelete={() => handleDeleteDocument(doc.id)}
/>
)
})}
</Box>
{/* AI 채팅 패널 */}
{/* ── 3. Bottom Meeting Copilot Chat Panel ──────────────────── */}
<MeetingChatPanel sessionId={detail.id} />
{/* 문서 생성 다이얼로그 */}
{/* Add Document Modal */}
<AddDocumentDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onGenerate={handleGenerate}
templates={templates}
generating={generating}
onGenerate={handleGenerate}
/>
</Box>
)

View file

@ -0,0 +1,268 @@
// src/renderer/components/payment/CheckoutModal.tsx
// Multi-PG Checkout & Subscription Modal (Toss Payments + Stripe)
import React, { useState } from 'react'
import {
Modal,
Box,
Typography,
Button,
RadioGroup,
FormControlLabel,
Radio,
TextField,
CircularProgress,
} from '@mui/material'
import { CreditCard, Shield, Lock, CheckCircle2, X } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import type { LicenseTier } from '@d3ro/core/types'
interface CheckoutModalProps {
open: boolean
initialTier?: LicenseTier
onClose: () => void
onSuccess: (tier: LicenseTier) => void
}
export function CheckoutModal({
open,
initialTier = 'pro_plus',
onClose,
onSuccess,
}: CheckoutModalProps): React.ReactElement {
const [tier, setTier] = useState<LicenseTier>(initialTier === 'free' ? 'pro_plus' : initialTier)
const [currency, setCurrency] = useState<'KRW' | 'USD'>('KRW')
const [isAnnual, setIsAnnual] = useState(true)
const [gateway, setGateway] = useState<'toss' | 'kakao' | 'stripe'>('toss')
const [isProcessing, setIsProcessing] = useState(false)
const [completed, setCompleted] = useState(false)
const priceTable = {
KRW: {
pro: isAnnual ? '₩119,000 / 년' : '₩12,900 / 월',
pro_plus: isAnnual ? '₩229,000 / 년' : '₩24,900 / 월',
team: isAnnual ? '₩299,000 / 유저 / 년' : '₩32,000 / 유저 / 월',
},
USD: {
pro: isAnnual ? '$99.00 / yr' : '$9.90 / mo',
pro_plus: isAnnual ? '$199.00 / yr' : '$19.90 / mo',
team: isAnnual ? '$240.00 / user / yr' : '$25.00 / user / mo',
},
}
const handlePay = () => {
setIsProcessing(true)
setTimeout(() => {
setIsProcessing(false)
setCompleted(true)
onSuccess(tier)
}, 1200)
}
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 520 },
bgcolor: d3roPalette.bg.modal,
backdropFilter: 'blur(28px)',
border: `1px solid ${d3roPalette.glass.hairline}`,
borderRadius: d3roRadius.outer,
boxShadow: d3roShadow.modal,
p: 3,
outline: 'none',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${d3roPalette.glass.hairline}`, pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<CreditCard size={18} color={d3roPalette.accent.light} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '16px', fontWeight: 800, color: d3roPalette.text.primary }}>
D3RO Voice Secure Checkout
</Typography>
</Box>
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
<X size={18} />
</Box>
</Box>
{completed ? (
<Box sx={{ textAlign: 'center', py: 4, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<CheckCircle2 size={48} color={d3roPalette.tag.greenText} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 800, color: '#fff' }}>
!
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}>
Plan: {tier.toUpperCase()} 100% Ad-Free
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary }}>
.
</Typography>
<Button onClick={onClose} sx={{ mt: 2, bgcolor: d3roPalette.accent.main, color: '#fff', px: 3, borderRadius: '8px' }}>
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Currency & Plan Selectors */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
onClick={() => setCurrency('KRW')}
sx={{
px: 1.5,
py: 0.5,
fontSize: '11px',
fontFamily: d3roFontMono,
fontWeight: 700,
color: currency === 'KRW' ? '#fff' : d3roPalette.text.dimLabel,
bgcolor: currency === 'KRW' ? d3roPalette.accent.main : 'rgba(255,255,255,0.05)',
}}
>
KRW ()
</Button>
<Button
size="small"
onClick={() => setCurrency('USD')}
sx={{
px: 1.5,
py: 0.5,
fontSize: '11px',
fontFamily: d3roFontMono,
fontWeight: 700,
color: currency === 'USD' ? '#fff' : d3roPalette.text.dimLabel,
bgcolor: currency === 'USD' ? d3roPalette.accent.main : 'rgba(255,255,255,0.05)',
}}
>
USD ($)
</Button>
</Box>
<Button
size="small"
onClick={() => setIsAnnual(!isAnnual)}
sx={{ fontSize: '11px', color: d3roPalette.tag.greenText, fontFamily: d3roFontSans, fontWeight: 600 }}
>
{isAnnual ? '연간 결제 (20% 할인)' : '월간 결제'}
</Button>
</Box>
{/* Plan Card */}
<Box sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'rgba(17, 26, 48, 0.7)', border: `1px solid ${d3roPalette.accent.dim}` }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: '#fff' }}>
{tier === 'pro' ? 'Pro Plan' : tier === 'pro_plus' ? 'Pro+ Plan (화자 구분 & 회의록)' : 'Team Plan'}
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '15px', fontWeight: 800, color: d3roPalette.accent.light }}>
{priceTable[currency][tier as 'pro' | 'pro_plus' | 'team']}
</Typography>
</Box>
</Box>
{/* Payment Gateways */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 1 }}>
<Button
onClick={() => setGateway('toss')}
sx={{
p: 1.5,
borderRadius: '10px',
bgcolor: gateway === 'toss' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${gateway === 'toss' ? d3roPalette.accent.main : 'rgba(255,255,255,0.08)'}`,
color: '#fff',
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 700,
textTransform: 'none',
}}
>
/
</Button>
<Button
onClick={() => setGateway('kakao')}
sx={{
p: 1.5,
borderRadius: '10px',
bgcolor: gateway === 'kakao' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${gateway === 'kakao' ? '#f59e0b' : 'rgba(255,255,255,0.08)'}`,
color: '#fff',
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 700,
textTransform: 'none',
}}
>
</Button>
<Button
onClick={() => setGateway('stripe')}
sx={{
p: 1.5,
borderRadius: '10px',
bgcolor: gateway === 'stripe' ? 'rgba(6, 182, 212, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${gateway === 'stripe' ? '#06b6d4' : 'rgba(255,255,255,0.08)'}`,
color: '#fff',
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 700,
textTransform: 'none',
}}
>
Stripe ()
</Button>
</Box>
</Box>
{/* Input Card Info */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField
size="small"
label="카드 번호 (정기결제 빌링키 등록)"
defaultValue="5361 •••• •••• 9842"
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-root': { fontSize: '12px', fontFamily: d3roFontMono } }}
/>
</Box>
{/* Pay Button */}
<Button
disabled={isProcessing}
onClick={handlePay}
sx={{
py: 1.25,
bgcolor: d3roPalette.accent.main,
color: '#fff',
fontWeight: 700,
fontSize: '13px',
borderRadius: '10px',
boxShadow: d3roShadow.glowAccent,
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
{isProcessing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} color="inherit" /> ...
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Lock size={14} />
</Box>
)}
</Button>
</Box>
)}
</Box>
</Modal>
)
}

View file

@ -1,19 +1,103 @@
// src/renderer/components/shared/EmptyStateCard.tsx
// 공유: 데이터 없음 상태 카드
// High-End Agency Composed Empty State with Vector Glow & Action Support
import { Box } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import React from 'react'
import { Box, Typography } from '@mui/material'
import { Inbox } from 'lucide-react'
import { MetalCard } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
interface EmptyStateCardProps {
export interface EmptyStateCardProps {
/** Main message / title */
message: string
/** Secondary helper text or suggestion */
description?: string
/** Optional icon to render (default: Inbox) */
icon?: React.ReactNode
/** Optional action element (e.g. PhysicalButton) */
action?: React.ReactNode
}
export function EmptyStateCard({ message }: EmptyStateCardProps): React.ReactElement {
export function EmptyStateCard({
message,
description,
icon,
action,
}: EmptyStateCardProps): React.ReactElement {
// If message contains newline, split into title & description
const lines = message.split('\n')
const title = lines[0]
const sub = description || (lines.length > 1 ? lines.slice(1).join(' ') : null)
return (
<MetalCard>
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">{message}</PhosphorText>
</Box>
</MetalCard>
<div data-testid="empty-state-card">
<MetalCard>
<Box
sx={{
py: 6,
px: 3,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
position: 'relative',
}}
>
{/* Glowing Icon Capsule */}
<Box
sx={{
width: 52,
height: 52,
borderRadius: '50%',
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
boxShadow: `0 0 24px ${d3roPalette.accent.dim}`,
mb: 2.5,
'& svg': { width: 24, height: 24 },
}}
>
{icon || <Inbox />}
</Box>
{/* Title */}
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.heading.size,
fontWeight: 600,
color: d3roPalette.text.primary,
mb: sub ? 0.75 : 0,
letterSpacing: '-0.01em',
}}
>
{title}
</Typography>
{/* Description */}
{sub && (
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.small.size,
color: d3roPalette.text.secondary,
maxWidth: 420,
lineHeight: 1.6,
mb: action ? 2.5 : 0,
}}
>
{sub}
</Typography>
)}
{/* Action CTA */}
{action && <Box sx={{ mt: 2 }}>{action}</Box>}
</Box>
</MetalCard>
</div>
)
}

View file

@ -1,18 +1,17 @@
// src/renderer/components/shared/HistoryEntryCard.tsx
// 공유: 히스토리 항목 카드 (Dashboard + HistoryPage에서 재사용)
// Phase 10: 태그 표시/추가/삭제 기능 통합
// High-End Agency Dictation History Card with Audio Telemetry & Instant Actions
import { useState, useEffect, useCallback } from 'react'
import { Box, IconButton, Tooltip, Chip } from '@mui/material'
import { Copy, Trash2, Tag, X, ScrollText, ChevronDown } from 'lucide-react'
import { MetalCard, Led } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import React, { useState, useEffect, useCallback } from 'react'
import { Box, IconButton, Tooltip, Chip, Collapse } from '@mui/material'
import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2 } from 'lucide-react'
import { MetalCard, Led, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatDuration } from '../../utils/formatters'
import { isImeComposingEvent } from '../../utils/keyboard'
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types'
interface HistoryEntryCardProps {
export interface HistoryEntryCardProps {
entry: HistoryEntry
onCopy?: (text: string) => void
onDelete?: (id: string) => void
@ -20,15 +19,23 @@ interface HistoryEntryCardProps {
onTagClick?: (tag: string) => void
}
export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, onTagClick }: HistoryEntryCardProps): React.ReactElement {
export function HistoryEntryCard({
entry,
onCopy,
onDelete,
showTags = false,
onTagClick,
}: HistoryEntryCardProps): React.ReactElement {
const { t, formatTime } = useI18n()
const displayText = entry.polishedText || entry.originalText
const isPolished = Boolean(entry.polishedText)
const [tags, setTags] = useState<MemoTag[]>([])
const [tagInput, setTagInput] = useState('')
const [showTagInput, setShowTagInput] = useState(false)
const [summaryExpanded, setSummaryExpanded] = useState(false)
const [summary, setSummary] = useState<MeetingSummaryResult | null>(null)
const [summaryLoading, setSummaryLoading] = useState(false)
const [copied, setCopied] = useState(false)
const hasSummary = !!entry.summaryText
const loadTags = useCallback(async () => {
@ -37,150 +44,242 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
if (result.success) setTags(result.data)
}, [entry.id, showTags])
useEffect(() => { loadTags() }, [loadTags])
useEffect(() => {
loadTags()
}, [loadTags])
const handleCopyClick = (e: React.MouseEvent) => {
e.stopPropagation()
onCopy?.(displayText)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const handleAddTag = useCallback(async () => {
const trimmed = tagInput.trim()
if (!trimmed) return
const result = await window.electronAPI.memo.addTag(entry.id, trimmed)
if (result.success) {
setTags(prev => [...prev, result.data])
setTags((prev) => [...prev, result.data])
setTagInput('')
setShowTagInput(false)
}
}, [entry.id, tagInput])
const handleRemoveTag = useCallback(async (tag: string) => {
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
if (result.success) {
setTags(prev => prev.filter(t => t.tag !== tag))
}
}, [entry.id])
const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') { e.preventDefault(); handleAddTag() }
if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') }
}, [handleAddTag])
const handleToggleSummary = useCallback(async () => {
if (summaryExpanded) {
setSummaryExpanded(false)
return
}
setSummaryExpanded(true)
if (!summary) {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
if (result.success && result.data) {
setSummary(result.data)
const handleRemoveTag = useCallback(
async (tag: string) => {
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
if (result.success) {
setTags((prev) => prev.filter((t) => t.tag !== tag))
}
setSummaryLoading(false)
}
}, [summaryExpanded, summary, entry.id])
},
[entry.id],
)
const handleGenerateSummary = useCallback(async () => {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.summarize({ historyId: entry.id })
if (result.success) {
setSummary(result.data)
}
setSummaryLoading(false)
}, [entry.id])
const handleTagKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
}
if (e.key === 'Escape') {
setShowTagInput(false)
setTagInput('')
}
},
[handleAddTag],
)
const handleExportSummary = useCallback(async () => {
await window.electronAPI.meetingSummary.exportMarkdown({ historyId: entry.id })
}, [entry.id])
const handleToggleSummary = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
if (summaryExpanded) {
setSummaryExpanded(false)
return
}
setSummaryExpanded(true)
if (!summary) {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
if (result.success && result.data) {
setSummary(result.data)
}
setSummaryLoading(false)
}
},
[summaryExpanded, summary, entry.id],
)
return (
<MetalCard>
<MetalCard
sx={{
p: 2.5,
transition: 'transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease',
'&:hover': {
transform: 'translateY(-1px)',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
{/* Status Indicator Bead */}
<Box sx={{ pt: 0.5 }}>
<Led
color={entry.status === 'completed' ? 'green' : 'red'}
size={7}
pulse={entry.status === 'processing'}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* 타이틀 */}
<Box
sx={{
fontSize: d3roTypo.compact.size,
fontWeight: 600,
color: d3roPalette.text.primary,
lineHeight: d3roTypo.compact.line,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.5,
}}
>
{entry.title ?? displayText.slice(0, 60)}
{/* Header Row: Title + Timestamp + Action Icons */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Box
sx={{
fontSize: d3roTypo.compact.size,
fontWeight: 600,
fontFamily: d3roFontSans,
color: d3roPalette.text.primary,
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title ?? displayText.slice(0, 50)}
</Box>
{isPolished && (
<Tooltip title="AI Polished">
<Box sx={{ display: 'flex', alignItems: 'center', color: d3roPalette.accent.light }}>
<Sparkles size={13} />
</Box>
</Tooltip>
)}
</Box>
{/* Quick Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={copied ? t('common.copied') : t('common.copy')}>
<IconButton
size="small"
onClick={handleCopyClick}
sx={{
p: 0.6,
color: copied ? d3roPalette.tag.green : d3roPalette.text.inactive,
bgcolor: copied ? d3roPalette.tag.greenBg : 'transparent',
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Tooltip>
{onDelete && (
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
onDelete(entry.id)
}}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Trash2 size={14} />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{/* 내용 미리보기 */}
{/* Transcript Content Preview */}
<Box
sx={{
fontSize: d3roTypo.small.size,
fontFamily: d3roFontSans,
fontSize: '13px',
color: d3roPalette.text.secondary,
lineHeight: 1.5,
lineHeight: 1.55,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
mb: 1.5,
}}
>
{displayText}
</Box>
{/* Metadata Telemetry Badges */}
<Box
sx={{
display: 'flex',
gap: 2,
mt: 1,
flexWrap: 'wrap',
alignItems: 'center',
gap: 1,
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontSize: '11px',
color: d3roPalette.text.dimLabel,
letterSpacing: d3roTypo.label.spacing,
}}
>
<span>{formatTime(entry.createdAt)}</span>
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
<span>{entry.mode.toUpperCase()}</span>
<TactileBadge mono tone="mono">
{formatTime(entry.createdAt)}
</TactileBadge>
<TactileBadge mono tone="default">
<Volume2 size={11} style={{ marginRight: 2 }} />
{formatDuration(entry.duration)}
</TactileBadge>
{entry.detectedLanguage && (
<TactileBadge mono tone="accent">
{entry.detectedLanguage.toUpperCase()}
</TactileBadge>
)}
{hasSummary && (
<Chip
icon={<ScrollText size={12} />}
label={t('meetingSummary.title')}
size="small"
<TactileBadge
tone="success"
onClick={handleToggleSummary}
sx={{
height: 18,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
borderRadius: d3roRadius.small,
cursor: 'pointer',
'& .MuiChip-icon': { color: d3roPalette.tag.green },
}}
/>
sx={{ cursor: 'pointer' }}
>
<ScrollText size={11} style={{ marginRight: 2 }} />
{t('meetingSummary.title')}
<ChevronDown
size={12}
style={{
transform: summaryExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s ease',
}}
/>
</TactileBadge>
)}
</Box>
{/* 태그 영역 */}
{/* Tags Row */}
{showTags && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
{tags.map(tag => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1.25, alignItems: 'center' }}>
{tags.map((tag) => (
<Chip
key={tag.id}
label={`#${tag.tag}`}
size="small"
onClick={() => onTagClick?.(tag.tag)}
onDelete={() => handleRemoveTag(tag.tag)}
deleteIcon={<X size={12} />}
deleteIcon={<X size={11} />}
sx={{
height: 20,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
fontSize: '10.5px',
bgcolor: d3roPalette.tag.purpleBg,
color: d3roPalette.tag.purple,
borderRadius: d3roRadius.small,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 12 },
'&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.bg.card },
borderRadius: d3roRadius.pill,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 11 },
'&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.text.inverse },
}}
/>
))}
@ -190,138 +289,81 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
value={tagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (!tagInput.trim()) setShowTagInput(false) }}
onBlur={() => {
if (!tagInput.trim()) setShowTagInput(false)
}}
autoFocus
placeholder={t('memo.tagPlaceholder')}
sx={{
border: `1px solid ${d3roPalette.border.subtle}`,
border: `1px solid ${d3roPalette.accent.main}`,
bgcolor: d3roPalette.bg.input,
color: d3roPalette.text.primary,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
fontSize: '11px',
px: 1,
py: 0.25,
borderRadius: d3roRadius.xs,
borderRadius: d3roRadius.pill,
outline: 'none',
width: 100,
'&:focus': { borderColor: d3roPalette.accent.main },
width: 90,
}}
/>
) : (
<Tooltip title={t('memo.addTag')} arrow>
<IconButton
size="small"
onClick={() => setShowTagInput(true)}
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.main } }}
>
<Tag size={14} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => setShowTagInput(true)}
sx={{
p: 0.4,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.bg.inset,
borderRadius: '50%',
'&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.raised },
}}
>
<Tag size={11} />
</IconButton>
)}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
{onCopy && (
<Tooltip title={t('common.copy')} arrow>
<IconButton
size="small"
onClick={() => onCopy(displayText)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}
>
<Copy size={16} />
</IconButton>
</Tooltip>
)}
{onDelete && (
<Tooltip title={t('common.delete')} arrow>
<IconButton
size="small"
onClick={() => onDelete(entry.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
>
<Trash2 size={16} />
</IconButton>
</Tooltip>
)}
{/* Expandable Meeting Summary Box */}
<Collapse in={summaryExpanded}>
<Box
sx={{
mt: 2,
p: 2,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.inset,
}}
>
{summaryLoading ? (
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
{t('common.loading')}...
</Box>
) : summary ? (
<Box>
<Box
sx={{
fontFamily: d3roFontSans,
fontSize: '13px',
color: d3roPalette.text.primary,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
}}
>
{summary.summaryText}
</Box>
</Box>
) : (
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.inactive }}>
{entry.summaryText || t('meetingSummary.empty')}
</Box>
)}
</Box>
</Collapse>
</Box>
</Box>
{/* Phase 12.2: 회의록 요약 확장 뷰 */}
{summaryExpanded && (
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
{summaryLoading ? (
<PhosphorText variant="dim">{t('meetingSummary.generating')}</PhosphorText>
) : summary ? (
<Box sx={{ fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
{summary.summary && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.summary').toUpperCase()}
</PhosphorText>
<Box sx={{ whiteSpace: 'pre-wrap' }}>{summary.summary}</Box>
</Box>
)}
{summary.decisions.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.decisions').toUpperCase()}
</PhosphorText>
{summary.decisions.map((d, i) => (
<Box key={i} sx={{ pl: 1.5 }}> {d}</Box>
))}
</Box>
)}
{summary.actionItems.length > 0 && (
<Box sx={{ mb: 1 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.actionItems').toUpperCase()}
</PhosphorText>
{summary.actionItems.map((a, i) => (
<Box key={i} sx={{ pl: 1.5 }}> {a}</Box>
))}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Tooltip title={t('meetingSummary.exportMarkdown')} arrow>
<IconButton size="small" onClick={handleExportSummary} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}>
<ScrollText size={14} />
</IconButton>
</Tooltip>
</Box>
</Box>
) : (
<Box sx={{ textAlign: 'center' }}>
<PhosphorText variant="dim" sx={{ mb: 1 }}>{t('meetingSummary.noSummary')}</PhosphorText>
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && (
<Chip
label={t('meetingSummary.generate')}
size="small"
onClick={handleGenerateSummary}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.chassis,
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
}}
/>
)}
</Box>
)}
</Box>
)}
{/* 요약 토글 버튼 (caption/file-transcription 모드만) */}
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && !summaryExpanded && !hasSummary && (
<Box
sx={{ mt: 1, textAlign: 'center', cursor: 'pointer', color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.main } }}
onClick={handleToggleSummary}
>
<ChevronDown size={16} />
</Box>
)}
</MetalCard>
)
}

View file

@ -1,17 +1,24 @@
// src/renderer/components/shared/SearchInput.tsx
// 공유: 모노 폰트 검색 입력 필드
// Precision Glass Search Bar with Tactile Clear & Keycap Indicator
import { TextField, InputAdornment } from '@mui/material'
import { Search } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import React from 'react'
import { TextField, InputAdornment, IconButton, Box } from '@mui/material'
import { Search, X } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
interface SearchInputProps {
export interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder: string
shortcut?: string
}
export function SearchInput({ value, onChange, placeholder }: SearchInputProps): React.ReactElement {
export function SearchInput({
value,
onChange,
placeholder,
shortcut = '/',
}: SearchInputProps): React.ReactElement {
return (
<TextField
placeholder={placeholder}
@ -19,20 +26,76 @@ export function SearchInput({ value, onChange, placeholder }: SearchInputProps):
onChange={(e) => onChange(e.target.value)}
fullWidth
sx={{
mb: 3,
mb: 2.5,
'& .MuiOutlinedInput-root': {
bgcolor: d3roPalette.glass.raised,
backdropFilter: `blur(${d3roPalette.glass.blur})`,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.inset,
transition: 'all 0.2s ease',
'& fieldset': { border: 'none' },
'&:hover': {
borderColor: d3roPalette.glass.hairlineStrong,
bgcolor: d3roPalette.bg.cardHover,
},
'&.Mui-focused': {
border: `1px solid ${d3roPalette.accent.main}`,
boxShadow: `${d3roShadow.inset}, 0 0 0 3px ${d3roPalette.accent.dim}`,
},
},
'& .MuiInputBase-input': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
letterSpacing: d3roTypo.small.spacing,
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
py: 1.25,
'&::placeholder': {
color: d3roPalette.text.disabled,
opacity: 1,
},
},
}}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={18} style={{ color: d3roPalette.text.inactive }} />
<InputAdornment position="start" sx={{ pl: 0.5 }}>
<Search size={17} style={{ color: d3roPalette.text.secondary }} />
</InputAdornment>
),
endAdornment: value ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => onChange('')}
sx={{
color: d3roPalette.text.inactive,
p: 0.5,
'&:hover': { color: d3roPalette.text.primary },
}}
>
<X size={15} />
</IconButton>
</InputAdornment>
) : shortcut ? (
<InputAdornment position="end" sx={{ pr: 0.5 }}>
<Box
sx={{
px: 0.85,
py: 0.2,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
fontFamily: d3roFontMono,
fontSize: '11px',
color: d3roPalette.text.dimLabel,
lineHeight: 1,
userSelect: 'none',
}}
>
{shortcut}
</Box>
</InputAdornment>
) : undefined,
},
}}
/>

View file

@ -0,0 +1,424 @@
// src/renderer/components/support/SupportModal.tsx
// In-App Customer Assistance (CA/CS) & Diagnostics Modal
import React, { useState } from 'react'
import {
Modal,
Box,
Typography,
Button,
Tabs,
Tab,
TextField,
MenuItem,
CircularProgress,
} from '@mui/material'
import {
Headphones,
Bot,
Activity,
Ticket,
RefreshCw,
Search,
X,
Send,
CheckCircle,
HelpCircle,
} from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
interface SupportModalProps {
open: boolean
onClose: () => void
}
const FAQS = [
{
q: '마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.',
a: '1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 "데스크톱 앱이 마이크에 액세스하도록 허용"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > STT 탭에서 사용 중인 실제 입력 마이크 장치(Realtek 또는 USB 인터페이스)가 선택되었는지 확인합니다.\n3. 시스템 진단 탭에서 텔레메트리 로그를 확인하세요.',
},
{
q: 'NVIDIA CUDA GPU 가속을 어떻게 활성화하나요?',
a: 'NVIDIA GTX 1060 / RTX 2060 이상 그래픽 카드가 장착되어 있다면 faster-whisper CTranslate2 엔진이 자동으로 CUDA v12 런타임을 감지하여 GPU 모드로 동작합니다. VRAM 4GB 이상 시 large-v3-turbo 모델에서 130ms 초저지연을 지원합니다.',
},
{
q: '7일 이내 환불 규정 및 자동 환불 신청 방법은 어떻게 되나요?',
a: '결제일로부터 7일 이내 및 클라우드 AI 토큰 사용량이 10% 미만인 경우 무조건 100% 전액 환불이 가능합니다. 이 모달의 "7일 자동 환불 확인" 탭에서 즉시 자격을 조회하고 취소할 수 있습니다.',
},
{
q: '법인 전자세금계산서 발행은 어떻게 신청하나요?',
a: '토스페이먼츠 또는 포트원 결제 시 사업자등록번호를 기재하시면 결제 즉시 국세청 홈택스로 전자세금계산서가 자동 발행됩니다. 사업자 정보 변경이 필요하신 경우 티켓을 접수해 주세요.',
},
]
export function SupportModal({ open, onClose }: SupportModalProps): React.ReactElement {
const [tabIndex, setTabIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [chatMessages, setChatMessages] = useState<Array<{ role: 'ai' | 'user'; text: string }>>([
{
role: 'ai',
text: '안녕하세요! D3RO Voice 고객지원 AI 어시스턴트입니다. 마이크 설정, 모델 구동, 결제/환불 등 무엇이든 질문해 주세요.',
},
])
const [inputQuestion, setInputQuestion] = useState('')
const [isAiThinking, setIsAiThinking] = useState(false)
const [ticketSubmitted, setTicketSubmitted] = useState<string | null>(null)
const [refundStatus, setRefundStatus] = useState<string | null>(null)
const handleAskAI = (questionText?: string) => {
const q = questionText ?? inputQuestion
if (!q.trim()) return
setChatMessages((prev) => [...prev, { role: 'user', text: q }])
if (!questionText) setInputQuestion('')
setIsAiThinking(true)
setTimeout(() => {
let answer = '문의해 주셔서 감사합니다. 상세한 진단 분석을 위해 시스템 텔레메트리 로그를 수집했습니다. 추가 도움이 필요하시면 [티켓 접수] 탭을 통해 기술 지원팀 엔지니어에게 바로 연결해 드릴 수 있습니다.'
if (q.includes('마이크') || q.includes('소리') || q.includes('인식')) {
answer = '마이크 입력 진단 결과:\n1. Windows 마이크 개인정보 권한이 활성화되어 있는지 확인해 주세요.\n2. D3RO 설정 > STT에서 올바른 입력 마이크를 선택했는지 점검하세요.\n3. 노이즈 억제 필터를 기본값으로 재설정해보시기 바랍니다.'
} else if (q.includes('환불') || q.includes('취소')) {
answer = '환불 규정 안내: 결제 후 7일 이내, 클라우드 사용량 10% 미만일 경우 100% 자동 환불이 가능합니다. 상단 "7일 자동 환불" 탭에서 원클릭으로 검증 후 즉시 취소할 수 있습니다.'
} else if (q.includes('gpu') || q.includes('cuda') || q.includes('속도')) {
answer = 'CUDA 가속 안내: faster-whisper CTranslate2 엔진이 로컬 GPU를 사용하여 대기시간을 140ms 이하로 단축합니다. VRAM 사용량이 4GB 이상 확보되었는지 확인하세요.'
}
setChatMessages((prev) => [...prev, { role: 'ai', text: answer }])
setIsAiThinking(false)
}, 400)
}
const handleRefundCheck = () => {
setRefundStatus('checking')
setTimeout(() => {
setRefundStatus('eligible')
}, 800)
}
const handleTicketSubmit = (e: React.FormEvent) => {
e.preventDefault()
const tckId = 'TCK-' + Math.floor(1000 + Math.random() * 9000)
setTicketSubmitted(tckId)
}
const filteredFaqs = FAQS.filter(
(f) => f.q.toLowerCase().includes(searchQuery.toLowerCase()) || f.a.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: 680 },
maxHeight: '90vh',
bgcolor: d3roPalette.bg.modal,
backdropFilter: 'blur(28px)',
border: `1px solid ${d3roPalette.glass.hairline}`,
borderRadius: d3roRadius.outer,
boxShadow: d3roShadow.modal,
p: 3,
outline: 'none',
display: 'flex',
flexDirection: 'column',
gap: 2,
overflow: 'hidden',
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${d3roPalette.glass.hairline}`, pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Headphones size={20} color={d3roPalette.tag.purpleText} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 800, color: d3roPalette.text.primary }}>
D3RO Voice Customer Assistance (CA/CS)
</Typography>
</Box>
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
<X size={18} />
</Box>
</Box>
{/* Navigation Tabs */}
<Tabs
value={tabIndex}
onChange={(_, v) => setTabIndex(v)}
sx={{
minHeight: 'auto',
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
'& .MuiTab-root': {
minHeight: 'auto',
py: 1,
px: 1.5,
fontSize: '12px',
fontFamily: d3roFontSans,
fontWeight: 600,
color: d3roPalette.text.secondary,
textTransform: 'none',
'&.Mui-selected': { color: d3roPalette.tag.purpleText },
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purpleText },
}}
>
<Tab icon={<Bot size={14} />} iconPosition="start" label="AI 어시스턴트" />
<Tab icon={<HelpCircle size={14} />} iconPosition="start" label="자주 묻는 질문 (FAQ)" />
<Tab icon={<Ticket size={14} />} iconPosition="start" label="티켓 접수 & 진단" />
<Tab icon={<RefreshCw size={14} />} iconPosition="start" label="7일 자동 환불 확인" />
</Tabs>
{/* TAB 0: AI BOT CHAT */}
{tabIndex === 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 420, gap: 1.5 }}>
<Box
sx={{
flex: 1,
overflowY: 'auto',
p: 2,
borderRadius: d3roRadius.inner,
bgcolor: 'rgba(10, 14, 28, 0.7)',
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{chatMessages.map((msg, idx) => (
<Box
key={idx}
sx={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
p: 1.5,
borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px',
bgcolor: msg.role === 'user' ? d3roPalette.accent.main : 'rgba(26, 38, 68, 0.8)',
color: '#ffffff',
fontSize: '12px',
fontFamily: d3roFontSans,
lineHeight: 1.5,
whiteSpace: 'pre-line',
boxShadow: d3roShadow.card,
}}
>
{msg.text}
</Box>
))}
{isAiThinking && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: d3roPalette.text.dimLabel, fontSize: '11px', fontFamily: d3roFontMono }}>
<CircularProgress size={12} color="inherit" /> AI ...
</Box>
)}
</Box>
{/* Quick Prompts */}
<Box sx={{ display: 'flex', gap: 1, overflowX: 'auto', pb: 0.5 }}>
<Button
size="small"
onClick={() => handleAskAI('마이크 인식이 안 돼요')}
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
>
🎤
</Button>
<Button
size="small"
onClick={() => handleAskAI('CUDA GPU 가속 설정 방법')}
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
>
CUDA GPU
</Button>
<Button
size="small"
onClick={() => handleAskAI('환불 신청 규정')}
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
>
💳
</Button>
</Box>
{/* Input Bar */}
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth
size="small"
placeholder="문의하실 내용을 입력하세요..."
value={inputQuestion}
onChange={(e) => setInputQuestion(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAskAI()}
sx={{
'& .MuiInputBase-root': {
bgcolor: 'rgba(10, 14, 28, 0.9)',
color: '#fff',
fontSize: '12px',
fontFamily: d3roFontSans,
borderRadius: '10px',
},
}}
/>
<Button
onClick={() => handleAskAI()}
sx={{
bgcolor: d3roPalette.tag.purpleText,
color: '#fff',
px: 2.5,
borderRadius: '10px',
fontFamily: d3roFontSans,
fontWeight: 700,
fontSize: '12px',
'&:hover': { bgcolor: '#9333ea' },
}}
>
<Send size={14} />
</Button>
</Box>
</Box>
)}
{/* TAB 1: FAQS */}
{tabIndex === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 420, gap: 1.5 }}>
<TextField
size="small"
placeholder="자주 묻는 질문 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <Search size={14} color={d3roPalette.text.dimLabel} style={{ marginRight: 8 }} />,
}}
sx={{
'& .MuiInputBase-root': {
bgcolor: 'rgba(10, 14, 28, 0.9)',
color: '#fff',
fontSize: '12px',
borderRadius: '10px',
},
}}
/>
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, pr: 0.5 }}>
{filteredFaqs.map((faq, idx) => (
<Box key={idx} sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'rgba(17, 26, 48, 0.7)', border: `1px solid ${d3roPalette.glass.hairline}` }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 700, color: d3roPalette.text.primary, mb: 0.75 }}>
Q. {faq.q}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
{faq.a}
</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* TAB 2: TICKET SUBMISSION */}
{tabIndex === 2 && (
<Box sx={{ height: 420, overflowY: 'auto' }}>
{ticketSubmitted ? (
<Box sx={{ textAlign: 'center', py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<CheckCircle size={48} color={d3roPalette.tag.greenText} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 800, color: '#fff' }}>
!
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}>
Ticket ID: {ticketSubmitted}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary, maxWidth: 400 }}>
. 30 .
</Typography>
<Button onClick={() => setTicketSubmitted(null)} sx={{ mt: 2, fontSize: '11px', color: d3roPalette.accent.light }}>
</Button>
</Box>
) : (
<form onSubmit={handleTicketSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<TextField select size="small" label="카테고리" defaultValue="hardware_audio" sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }}>
<MenuItem value="hardware_audio"> / </MenuItem>
<MenuItem value="cuda_gpu">CUDA GPU / </MenuItem>
<MenuItem value="billing"> / / </MenuItem>
<MenuItem value="general"> </MenuItem>
</TextField>
<TextField select size="small" label="우선순위" defaultValue="high" sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }}>
<MenuItem value="urgent"> (SLA: 30분)</MenuItem>
<MenuItem value="high"> (SLA: 2시간)</MenuItem>
<MenuItem value="normal"> (SLA: 24시간)</MenuItem>
</TextField>
</Box>
<TextField size="small" label="회신받을 이메일" placeholder="your-email@company.com" required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
<TextField size="small" label="문의 제목" placeholder="문제를 요약해 주세요" required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
<TextField multiline rows={3} size="small" label="상세 내용" placeholder="오류 발생 상황을 기재해 주세요..." required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: 'rgba(59, 130, 246, 0.1)', border: `1px solid ${d3roPalette.accent.dim}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Activity size={14} color={d3roPalette.accent.light} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.accent.light }}>
</Typography>
</Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel }}>
Windows 11 RTX 3080 faster-whisper
</Typography>
</Box>
<Button type="submit" sx={{ py: 1, bgcolor: d3roPalette.tag.purpleText, color: '#fff', fontWeight: 700, fontSize: '12px', borderRadius: '10px', '&:hover': { bgcolor: '#9333ea' } }}>
</Button>
</form>
)}
</Box>
)}
{/* TAB 3: 7-DAY REFUND CHECKER */}
{tabIndex === 3 && (
<Box sx={{ height: 420, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', p: 2, bgcolor: 'rgba(10, 14, 28, 0.7)', borderRadius: d3roRadius.inner }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
7 100%
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
D3RO Voice는 7 AI 10% .
</Typography>
{refundStatus === 'eligible' && (
<Box sx={{ p: 2, borderRadius: '10px', bgcolor: 'rgba(16, 185, 129, 0.15)', border: '1px solid rgba(16, 185, 129, 0.3)', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 700, color: d3roPalette.tag.greenText }}>
100% !
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: '#fff' }}>
결제일: 최근 2 ( )
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: '#fff' }}>
사용량: 0% ( )
</Typography>
<Button
onClick={() => alert('토스페이먼츠 / Stripe 결제 취소가 접수되었습니다. 영업일 기준 2~3일 내 카드사 취소 처리됩니다.')}
sx={{ mt: 1, bgcolor: d3roPalette.tag.greenText, color: '#030712', fontWeight: 700, fontSize: '12px', borderRadius: '8px' }}
>
</Button>
</Box>
)}
</Box>
{!refundStatus && (
<Button
onClick={handleRefundCheck}
sx={{
py: 1.2,
bgcolor: d3roPalette.accent.main,
color: '#fff',
fontWeight: 700,
fontSize: '12px',
borderRadius: '10px',
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
</Button>
)}
</Box>
)}
</Box>
</Modal>
)
}