diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 8714681..bc0a2b3 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -6,13 +6,13 @@ // 앱은 로그인 없이 바로 메인 UI에 진입해서 로컬 STT/LLM을 사용할 수 있다. // OAuth 로그인은 Settings → Cloud Sync 섹션에서 선택적으로 수행 (유료 SaaS 경로). -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect, useMemo } from 'react' import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material' import { getTheme } from '@d3ro/ui/theme' import { I18nProvider, useI18n, type I18nStorage, type Locale } from '@d3ro/i18n' import { AppLayout } from './components/AppLayout' import { UpgradePromptModal } from './components/UpgradePromptModal' -import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture' +import { createSystemAudioCaptureController } from './utils/systemAudioCapture' import type { ThemeMode, ConfigChangedEvent } from '@d3ro/core/types' // Electron ConfigService에 바인딩된 i18n 영속화 어댑터 @@ -44,7 +44,6 @@ function SyncedLocale(): null { export function App(): React.ReactElement { const [themeMode, setThemeMode] = useState('auto') const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') - const systemAudioCleanupRef = useRef<(() => void) | null>(null) // 설정에서 테마 로드 + 변경 감지 useEffect(() => { @@ -62,28 +61,28 @@ export function App(): React.ReactElement { return unsub }, []) - // 시스템 오디오 캡처: 메인 프로세스의 시작/중지 요청에 응답 + // 시스템 오디오 캡처: 메인 프로세스의 시작/중지 요청에 응답. + // 컨트롤러가 starting 중 stop을 취소로 처리하고 중복 start를 합쳐서, 빠른 on/off에도 + // 멈출 수 없는 루프백 캡처가 남지 않는다. useEffect(() => { + const capture = createSystemAudioCaptureController({ + onPcm: (pcm16Buffer) => window.electronAPI.caption.sendSystemAudioData(pcm16Buffer), + }) + const unsubStart = window.electronAPI.caption.onStartSystemAudio(() => { - startSystemAudioCapture((pcm16Buffer) => { - window.electronAPI.caption.sendSystemAudioData(pcm16Buffer) - }).catch(() => { + capture.start().catch(() => { // 시스템 오디오 캡처 실패 시 무시 — 마이크 자막만 사용 }) }) const unsubStop = window.electronAPI.caption.onStopSystemAudio(() => { - stopSystemAudioCapture() + capture.stop() }) - systemAudioCleanupRef.current = () => { - stopSystemAudioCapture() - } - return () => { unsubStart() unsubStop() - systemAudioCleanupRef.current?.() + capture.stop() } }, []) diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx index 3f34d5f..a974939 100644 --- a/apps/desktop/src/renderer/components/AppLayout.tsx +++ b/apps/desktop/src/renderer/components/AppLayout.tsx @@ -1,22 +1,18 @@ // src/renderer/components/AppLayout.tsx -// v3 "Tactile Liquid & Midnight Glass": Borderless Shell with Floating Island Navigation +// 메인 창 셸 — 2026-09-28 정보 구조 개편 ("기록이 주인공인 조용한 계기"). +// 타이틀바 · 사이드바(1급 홈·기록·회의 / 도구 / 설정) · 본문. 상태바는 없앴다: +// 버전·모델은 설정에, 시계는 OS 에 있고, "100% LOCAL PRIVACY · ZERO CLOUD SYNC"는 기기 간 동기화와 모순된 문구였다. +// 의미 있는 상태(로컬 AI 연결·자막·회의 녹음 중)는 사이드바 아래 한 줄로 옮겼다. -import React, { useState, useEffect } from 'react' -import { Alert, Box, Snackbar, Typography } from '@mui/material' -import { - LayoutDashboard, - History as HistoryIcon, - BookOpen, - SquareTerminal, - MessagesSquare, - Library, - Users, - Settings, - Headphones, -} from 'lucide-react' +import React, { useState, useEffect, useCallback } from 'react' +import { Alert, Box, Button, IconButton, Snackbar } from '@mui/material' +import { X } from 'lucide-react' import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' -import { Led, TactileBadge } from '@d3ro/ui/components/ds' -import { DashboardPage } from '../pages/DashboardPage' +import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react' +import { d3roPalette } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { LicenseTier } from '@d3ro/core/types' +import { HomePage } from '../pages/HomePage' import { HistoryPage } from '../pages/HistoryPage' import { DictionaryPage } from '../pages/DictionaryPage' import { CommandsPage } from '../pages/CommandsPage' @@ -24,66 +20,54 @@ import { VoiceConversationPage } from '../pages/VoiceConversationPage' import { KnowledgeBasePage } from '../pages/KnowledgeBasePage' import { MeetingModePage } from '../pages/MeetingModePage' import { SettingsModal } from './SettingsModal' -import { DEFAULT_SETTINGS_TAB, OPEN_SETTINGS_EVENT, readOpenSettingsTab } from '../navigation/settingsTabs' +import { OPEN_SETTINGS_EVENT, readOpenSettingsTab, DEFAULT_SETTINGS_TAB } from '../navigation/settingsTabs' import type { SettingsTabId } from '../navigation/settingsTabs' +import { presentError, settingsTabOf } from '../navigation/error-recovery' +import { AppNavigationProvider, PRIMARY_ROUTES, useAppNavigation, useRouteRequest } from '../navigation/app-navigation' import { LicenseModal } from './LicenseModal' import { OnboardingModal } from './OnboardingModal' +import { OllamaGuideModal } from './OllamaGuideModal' import { AdBanner } from './ads/AdBanner' import { RewardedQuotaModal } from './ads/RewardedQuotaModal' import { SupportModal } from './support/SupportModal' -import { StatusBar } from './StatusBar' import { TitleBar } from './TitleBar' -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' +import { AppSidebar } from './shell/AppSidebar' +import { NotifyProvider, useNotify } from './shared' -type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting' - -interface NavItem { - route: Route - labelKey: TranslationKey - icon: React.ReactElement +/** 입력 요소에 포커스가 있으면 전역 단축키를 무시한다 — 타자 중에 화면이 바뀌면 안 된다. */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) } -const ICON_SIZE = 18 - -const NAV_ITEMS: NavItem[] = [ - { route: 'dashboard', labelKey: 'nav.dashboard', icon: }, - { route: 'history', labelKey: 'nav.history', icon: }, - { route: 'dictionary', labelKey: 'nav.dictionary', icon: }, - { route: 'commands', labelKey: 'nav.commands', icon: }, - { route: 'conversation', labelKey: 'nav.conversation', icon: }, - { route: 'knowledge', labelKey: 'nav.knowledge', icon: }, - { route: 'meeting', labelKey: 'nav.meeting', icon: }, -] - -function tierToLedColor(tier: LicenseTier): 'amber' | 'green' { - switch (tier) { - case 'free': - return 'amber' - case 'pro': - case 'pro_plus': - case 'team': - case 'enterprise': - return 'green' - default: - return 'amber' - } -} - -export function AppLayout(): React.ReactElement { +function Shell(): React.ReactElement { const { t } = useI18n() - const [currentRoute, setCurrentRoute] = useState('dashboard') - const [settingsOpen, setSettingsOpen] = useState(false) - const [settingsInitialTab, setSettingsInitialTab] = useState(DEFAULT_SETTINGS_TAB) + const { route, navigate, back } = useAppNavigation() + const notify = useNotify() + const [settingsTab, setSettingsTab] = useState(DEFAULT_SETTINGS_TAB) const [onboardingOpen, setOnboardingOpen] = useState(false) const [licenseModalOpen, setLicenseModalOpen] = useState(false) const [supportModalOpen, setSupportModalOpen] = useState(false) const [rewardedModalOpen, setRewardedModalOpen] = useState(false) + const [ollamaGuideOpen, setOllamaGuideOpen] = useState(false) const [currentTier, setCurrentTier] = useState('free') const [fallbackMsg, setFallbackMsg] = useState(null) - const [voiceAlert, setVoiceAlert] = useState<{ message: string; severity: 'error' | 'warning' } | null>(null) + const [voiceAlert, setVoiceAlert] = useState<{ + message: string + severity: 'error' | 'warning' + /** 복구 딥링크(예: STT 모델 없음 → 설정 → STT). 없으면 닫기만. */ + settingsTab: SettingsTabId | null + } | null>(null) + const scrollRef = React.useRef(null) + + // 화면을 바꾸면 새 화면은 맨 위에서 시작한다 + useEffect(() => { + scrollRef.current?.osInstance()?.elements().viewport.scrollTo({ top: 0 }) + }, [route]) + + useRouteRequest('settings', (params) => { + if (params.settingsTab) setSettingsTab(params.settingsTab) + }) useEffect(() => { window.electronAPI.config.getAll().then((r) => { @@ -106,28 +90,27 @@ export function AppLayout(): React.ReactElement { setLicenseModalOpen(true) }) - const handleOpenLicenseModal = () => setLicenseModalOpen(true) + const handleOpenLicenseModal = (): void => setLicenseModalOpen(true) window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal) - // detail.tab 은 타입 없는 DOM 경계라 검증한다 — 모르는 값이면 기본 탭(빈 설정 창 방지) - const handleOpenSettings = (e: Event) => { - setSettingsInitialTab(readOpenSettingsTab(e)) - setSettingsOpen(true) + // detail.tab 은 타입 없는 DOM 경계라 검증한다 — 모르는 값이면 기본 탭(빈 설정 화면 방지) + const handleOpenSettings = (e: Event): void => { + const tab = readOpenSettingsTab(e) + setSettingsTab(tab) + navigate('settings', { settingsTab: tab }) } window.addEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings) + const handleOpenGuide = (): void => setOllamaGuideOpen(true) + window.addEventListener('d3ro:open-ollama-guide', handleOpenGuide) + const unsubVoiceError = window.electronAPI.voice.onError((e) => { + // 분류·복구는 error-recovery 정책이 정한다. STT 모델 없음(101)은 예전처럼 닫을 수 없는 온보딩을 + // 열지 않고, 모델을 받을 수 있는 설정 → STT 탭으로 가는 동작을 알림에 붙인다. const severity = e.severity ?? 'error' - let message = e.message - if (e.errorCode === 101) { - message = t('voice.error.modelMissing') - setOnboardingOpen(true) - } else if (e.errorCode === 103 || (e.errorCode >= 130 && e.errorCode <= 132)) { - message = t('voice.error.engine') - } else if (severity === 'warning' && e.errorCode === 300) { - message = t('voice.warning.llmSkipped') - } - setVoiceAlert({ message, severity }) + const presentation = presentError({ context: 'voice', code: e.errorCode, severity }) + const message = presentation.messageKey ? t(presentation.messageKey) : e.message + setVoiceAlert({ message, severity, settingsTab: settingsTabOf(presentation) }) }) const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => { @@ -145,213 +128,52 @@ export function AppLayout(): React.ReactElement { unsubVoiceError() window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal) window.removeEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings) + window.removeEventListener('d3ro:open-ollama-guide', handleOpenGuide) } - }, [t]) + }, [t, navigate]) + + // 키보드: Ctrl+1..3 = 1급 화면, Ctrl+, = 설정. 입력 중에는 무시. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent): void => { + if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || isTypingTarget(e.target)) return + const index = ['1', '2', '3'].indexOf(e.key) + if (index >= 0) { + e.preventDefault() + navigate(PRIMARY_ROUTES[index]) + } else if (e.key === ',') { + e.preventDefault() + navigate('settings') + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [navigate]) + + const openSettingsTab = useCallback( + (tab: SettingsTabId) => { + setSettingsTab(tab) + navigate('settings', { settingsTab: tab }) + }, + [navigate], + ) return ( - {/* ── Sidebar: Floating Island Aesthetic ─── */} - - {/* Brand Logo & Tier Badge */} - - - - - - D3RO - - - - VOICE - - + setSupportModalOpen(true)} + onOpenOllamaGuide={() => setOllamaGuideOpen(true)} + /> - {currentTier !== 'free' && ( - - - {currentTier.toUpperCase()} - - - )} - - - {/* Navigation Items */} - {NAV_ITEMS.map((item) => { - const isActive = currentRoute === item.route - return ( - setCurrentRoute(item.route)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.25, - px: 1.5, - 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: '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', - flexShrink: 0, - }, - }} - > - {item.icon} - - {t(item.labelKey)} - - - ) - })} - - {/* Customer Support Trigger */} - 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.purple, - bgcolor: d3roPalette.glass.raised, - borderColor: d3roPalette.tag.purple, - }, - }} - > - - - Customer Support - - - - {/* Settings Trigger */} - setSettingsOpen(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.text.primary, - bgcolor: d3roPalette.glass.raised, - borderColor: d3roPalette.glass.hairlineStrong, - }, - }} - > - - - {t('nav.settings')} - - - - - {/* ── Content Viewport: Ambient Mesh Background + Overlay Scrollbars ── */} - {currentRoute === 'dashboard' && } - {currentRoute === 'history' && } - {currentRoute === 'dictionary' && } - {currentRoute === 'commands' && } - {currentRoute === 'conversation' && } - {currentRoute === 'knowledge' && } - {currentRoute === 'meeting' && } + {route === 'home' && } + {route === 'records' && } + {route === 'meetings' && } + {route === 'conversation' && } + {route === 'commands' && } + {route === 'dictionary' && } + {route === 'knowledge' && } + {route === 'settings' && } - {/* Free Tier Subtle Dock Banner */} {currentTier === 'free' && ( - - setSettingsOpen(false)} /> setLicenseModalOpen(false)} /> setOnboardingOpen(false)} /> setSupportModalOpen(false)} /> + setOllamaGuideOpen(false)} /> setRewardedModalOpen(false)} - onRewardClaimed={(tokens) => { - alert(`🎉 ${tokens}개의 무료 클라우드 AI 정제 토큰이 충전되었습니다!`) - }} + onRewardClaimed={(tokens) => notify({ message: t('shell.rewardClaimed', { tokens }), severity: 'success' })} /> - {/* Fallback Banner */} setFallbackMsg(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setFallbackMsg(null)} sx={{ width: '100%' }}> + setFallbackMsg(null)} sx={{ width: '100%', bgcolor: d3roPalette.bg.elevated }}> {fallbackMsg} - {/* Voice Alert Banner */} setVoiceAlert(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setVoiceAlert(null)} - sx={{ width: '100%' }} + action={ + voiceAlert?.settingsTab ? ( + <> + + setVoiceAlert(null)}> + + + + ) : undefined + } + sx={{ width: '100%', bgcolor: d3roPalette.bg.elevated }} > {voiceAlert?.message ?? ''} @@ -429,3 +266,13 @@ export function AppLayout(): React.ReactElement { ) } + +export function AppLayout(): React.ReactElement { + return ( + + + + + + ) +} diff --git a/apps/desktop/src/renderer/components/CaptionModelSelect.tsx b/apps/desktop/src/renderer/components/CaptionModelSelect.tsx index a02163e..2cae72c 100644 --- a/apps/desktop/src/renderer/components/CaptionModelSelect.tsx +++ b/apps/desktop/src/renderer/components/CaptionModelSelect.tsx @@ -1,9 +1,11 @@ -// 실시간 자막 전용 로컬 Whisper 모델 선택 — STT 탭과 일반 탭 자막 섹션이 함께 쓴다. +// 실시간 자막 전용 로컬 Whisper 모델 선택 — STT 탭과 오디오 탭 자막 구획이 함께 쓴다. +// v4: 설정 행 한 줄(왼쪽 라벨·설명, 오른쪽 Select). -import { FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material' -import { d3roPalette, d3roTypo } from '@d3ro/ui/theme' +import { useId } from 'react' +import { FormControl, MenuItem, Select } from '@mui/material' import { useI18n } from '@d3ro/i18n' import type { STTModel } from '@d3ro/core/types' +import { SettingRow, SETTING_CONTROL_WIDTH } from './settings/SettingsRows' /** "받아쓰기와 같게" — 설정에는 null 로 저장한다 */ const SAME_AS_DICTATION = '__same__' @@ -13,29 +15,32 @@ interface CaptionModelSelectProps { /** 선택지로 보일 모델 — 받아 둔 모델만 넘긴다 */ models: readonly STTModel[] onChange: (modelId: string | null) => void - fullWidth?: boolean } -export function CaptionModelSelect({ value, models, onChange, fullWidth }: CaptionModelSelectProps): React.ReactElement { +export function CaptionModelSelect({ value, models, onChange }: CaptionModelSelectProps): React.ReactElement { const { t } = useI18n() + const labelId = useId() return ( - - {t('settings.captionModel')} - - - {t('settings.captionModel.desc')} - - + + + + } + /> ) } diff --git a/apps/desktop/src/renderer/components/CloudSyncSection.tsx b/apps/desktop/src/renderer/components/CloudSyncSection.tsx index 1da7197..17bb8a9 100644 --- a/apps/desktop/src/renderer/components/CloudSyncSection.tsx +++ b/apps/desktop/src/renderer/components/CloudSyncSection.tsx @@ -1,12 +1,14 @@ // src/renderer/components/CloudSyncSection.tsx // Phase V2-4: Settings에 표시되는 Cloud Sync 섹션 // 로그인 상태 / 지금 동기화 / 마지막 동기화 / 올릴 변경·거부된 변경 건수 +// v4(2026-09-28): 설정 행 문법 — 계정 · 동기화 상태 · 녹음 파일 동기화. 강조 카드 배경 제거. import { useCallback, useEffect, useState } from 'react' -import { Box, Button, Stack, Alert, CircularProgress, FormControlLabel, Switch } from '@mui/material' -import { Cloud, CloudCheck, Globe, GitBranch, RefreshCw } from 'lucide-react' -import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { Box, Button, Alert, CircularProgress, Switch } from '@mui/material' +import { Globe, GitBranch, RefreshCw } from 'lucide-react' +import { d3roPalette } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import { SettingRow, SettingsSection } from './settings/SettingsRows' interface CloudSyncState { authenticated: boolean @@ -120,105 +122,110 @@ export function CloudSyncSection(): React.ReactElement { : t('cloudSync.never') return ( - - - {state.authenticated ? ( - - ) : ( - + + + {!state.authenticated && ( + + + + + } + /> )} - {t('cloudSync.title')} - - - {t('cloudSync.description')} - - {!state.authenticated && ( - - {t('cloudSync.signIn')} - - - - - - )} - - {state.authenticated && ( - - - {t('cloudSync.signedInAs', { email: state.userEmail ?? t('cloudSync.noEmail') })} - - - - {t('cloudSync.lastSync', { time: lastSyncText })} - - 0 ? d3roPalette.tag.orange : d3roPalette.tag.green, - }} - > - {state.pendingChanges > 0 - ? t('cloudSync.pending', { count: state.pendingChanges }) - : t('cloudSync.upToDate')} - - {state.failedChanges > 0 && ( - - {t('cloudSync.failed', { count: state.failedChanges })} - - )} - - - - handleAudioSyncChange(e.target.checked)} />} - label={t('cloudSync.audio')} + {state.authenticated && ( + <> + void handleSignOut()} disabled={busy}> + {t('cloudSync.signOut')} + + } /> - - {t('cloudSync.audioDesc')} - - - - - - - - )} + + + {t('cloudSync.lastSync', { time: lastSyncText })} + + 0 ? d3roPalette.status.warning : undefined }} + > + {state.pendingChanges > 0 + ? t('cloudSync.pending', { count: state.pendingChanges }) + : t('cloudSync.upToDate')} + + {state.failedChanges > 0 && ( + + {t('cloudSync.failed', { count: state.failedChanges })} + + )} + + } + control={ + + } + /> - {error && ( - - {error} - - )} - {info && !error && ( - - {info} - - )} + handleAudioSyncChange(e.target.checked)} + inputProps={{ 'aria-labelledby': 'cloud-sync-audio-label' }} + /> + } + /> + + )} + + {error && ( + + {error} + + )} + {info && !error && ( + + {info} + + )} + ) } diff --git a/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx b/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx index a24f6e4..2d8bf22 100644 --- a/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx +++ b/apps/desktop/src/renderer/components/CodexOAuthGuideModal.tsx @@ -1,5 +1,6 @@ // src/renderer/components/CodexOAuthGuideModal.tsx -// OpenAI API Key 및 Codex OAuth / ChatGPT 구독자 연동 인터랙티브 가이드 모달 +// OpenAI API 키 · Codex OAuth(ChatGPT 구독) · 다른 STT 서비스 키 발급 안내 모달. STT 설정 탭에서 연다. +// v4(2026-09-28): 평면 다이얼로그 — 제목(h2) + 탭 + 번호 단계(소제목 + 설명, 1px 선). 근거 없는 성능·가격 수식어 제거. import { useState } from 'react' import { @@ -13,28 +14,75 @@ import { Typography, Button, IconButton, - Divider, - Paper, - Stack, - Chip, - Alert, + Tooltip, } 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' +import { X, Key, ExternalLink, Copy, Check, Terminal, Globe } from 'lucide-react' +import { d3roPalette, d3roFontMono, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { TranslationKey } from '@d3ro/i18n' interface CodexOAuthGuideModalProps { open: boolean onClose: () => void } -export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProps): React.ReactElement { - const [tab, setTab] = useState(0) - const [copiedIndex, setCopiedIndex] = useState(null) +interface ProviderLink { + name: string + descriptionKey: TranslationKey + url: string +} - const handleCopy = (text: string, index: number) => { +const PROVIDER_LINKS: readonly ProviderLink[] = [ + { name: 'Groq STT', descriptionKey: 'codexGuide.providers.groq', url: 'https://console.groq.com/keys' }, + { name: 'Deepgram Nova-3', descriptionKey: 'codexGuide.providers.deepgram', url: 'https://console.deepgram.com/' }, + { name: 'Google Gemini 2.0 Flash', descriptionKey: 'codexGuide.providers.google', url: 'https://aistudio.google.com/app/apikey' }, + { name: 'AssemblyAI Universal-2', descriptionKey: 'codexGuide.providers.assemblyai', url: 'https://www.assemblyai.com/dashboard' }, +] + +const DEVICE_AUTH_COMMAND = 'codex login --device-auth' + +const stepSx = { + py: 2, + borderBottom: `1px solid ${d3roPalette.border.subtle}`, + '&:last-of-type': { borderBottom: 'none' }, +} as const + +function StepTitle({ children }: { children: React.ReactNode }): React.ReactElement { + return ( + + {children} + + ) +} + +function StepText({ children, color }: { children: React.ReactNode; color?: string }): React.ReactElement { + return ( + + {children} + + ) +} + +function Intro({ title, body }: { title: string; body: string }): React.ReactElement { + return ( + + + {title} + + {body} + + ) +} + +export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProps): React.ReactElement { + const { t } = useI18n() + const [tab, setTab] = useState(0) + const [copied, setCopied] = useState(false) + + const handleCopy = (text: string) => { navigator.clipboard.writeText(text) - setCopiedIndex(index) - setTimeout(() => setCopiedIndex(null), 2000) + setCopied(true) + setTimeout(() => setCopied(false), 2000) } const handleOpenLink = (url: string) => { @@ -42,41 +90,14 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp } return ( - + - - - OpenAI & Codex OAuth 설정 안내 - - + {t('codexGuide.title')} + @@ -84,383 +105,141 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp setTab(v)} + variant="scrollable" + scrollButtons={false} sx={{ - bgcolor: d3roPalette.bg.sidebar, + px: 3, borderBottom: `1px solid ${d3roPalette.border.subtle}`, - minHeight: 44, - '& .MuiTab-root': { - fontFamily: d3roFontMono, - fontSize: d3roTypo.label.size, - fontWeight: 500, - letterSpacing: '0.5px', - color: d3roPalette.text.inactive, - minHeight: 44, - '&.Mui-selected': { color: d3roPalette.accent.main }, - }, - '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }, + '& .MuiTab-root': { ...typoSx('compact'), minHeight: 44, px: 1.5, gap: 0.75 }, }} > - } iconPosition="start" label="1. OpenAI API Key (추천)" /> - } iconPosition="start" label="2. Codex OAuth / ChatGPT 구독" /> - } iconPosition="start" label="3. 타사 AI STT 무료 키 발급" /> + } iconPosition="start" label={t('codexGuide.tab.apiKey')} /> + } iconPosition="start" label={t('codexGuide.tab.codex')} /> + } iconPosition="start" label={t('codexGuide.tab.providers')} /> - - {/* Tab 0: Direct OpenAI API Key */} + + {/* OpenAI API 키 */} {tab === 0 && ( - - } - sx={{ - bgcolor: d3roPalette.bg.inset, - color: d3roPalette.text.primary, - border: `1px solid ${d3roPalette.border.default}`, - '& .MuiAlert-icon': { color: d3roPalette.accent.main }, - }} - > - - 가장 빠르고 안정적인 연동 방법 (30초 소요) - - - OpenAI Whisper STT API는 사용한 만큼만 청구되는 분당 과금($0.006/분, 1시간에 약 500원 미만)으로, ChatGPT 웹 구독보다 훨씬 저렴하고 응답 속도가 빠릅니다. - - - - - - [STEP-BY-STEP 발급 절차] - - - - - - - 1단계: OpenAI Platform 대시보드 열기 - - + + + + + + + {t('codexGuide.apiKey.step1.title')} + {t('codexGuide.apiKey.step1.desc')} - - OpenAI 계정으로 로그인 후 API Keys 메뉴로 이동합니다. - - - - - - 2단계: 새 비밀 키(Secret Key) 생성 및 복사 - - - "+ Create new secret key" 버튼을 클릭하고 이름을 입력한 후 생성된 키(sk-proj-...)를 복사합니다. - - - - - - - 3단계: D3RO-Voice STT 설정에 붙여넣기 및 연결 테스트 - - - D3RO-Voice의 STT 탭에서 OpenAI Whisper를 선택하고, API Key 입력창에 붙여넣은 뒤 "연결 테스트" 버튼을 누르면 즉시 작동을 확인합니다. - - - + variant="contained" + endIcon={} + onClick={() => handleOpenLink('https://platform.openai.com/api-keys')} + sx={{ flexShrink: 0 }} + > + {t('codexGuide.apiKey.step1.action')} + + + + + {t('codexGuide.apiKey.step2.title')} + {t('codexGuide.apiKey.step2.desc')} + + {t('codexGuide.apiKey.step2.warning')} + + + + {t('codexGuide.apiKey.step3.title')} + {t('codexGuide.apiKey.step3.desc')} + - + )} - {/* Tab 1: Codex OAuth / ChatGPT Subscription */} + {/* Codex OAuth / ChatGPT 구독 */} {tab === 1 && ( - - } - sx={{ - bgcolor: d3roPalette.bg.inset, - color: d3roPalette.text.primary, - border: `1px solid ${d3roPalette.border.default}`, - '& .MuiAlert-icon': { color: d3roPalette.tag.orange }, - }} - > - - ChatGPT Plus / Pro / Team 구독자 안내 - - - ChatGPT 웹 구독과 OpenAI Platform API는 별개의 계정 체계입니다. Codex CLI 도구는 브라우저 OAuth(Device Flow)로 토큰을 교환하여 개발 환경에 인증합니다. - - - - - - [Codex Device Flow 인증 방법] - - - - - 1. 터미널에서 Device Auth 로그인 실행 - + + + + + {t('codexGuide.codex.step1.title')} - codex login --device-auth - handleCopy('codex login --device-auth', 1)} - sx={{ color: d3roPalette.accent.main }} - > - {copiedIndex === 1 ? : } - + + {DEVICE_AUTH_COMMAND} + + + handleCopy(DEVICE_AUTH_COMMAND)} + aria-label={t('codexGuide.copyCommand')} + sx={{ color: copied ? d3roPalette.status.success : d3roPalette.text.tertiary }} + > + {copied ? : } + + - - 화면에 표시되는 8자리 인증 코드를 확인하고 브라우저 인증 페이지에 입력하여 인증합니다. - - - - - - 2. 네트워크 문제 해결 팁 (Troubleshooting) - - - • localhost 콜백 차단: VPN이나 회사 프록시가 활성화된 경우 localhost 리디렉션이 차단될 수 있습니다. 이 경우 --device-auth 옵션을 사용하세요. - - - • 세션 만료 시: OAuth 토큰이 만료되면 다시 로그인을 진행하거나, 안전한 API Key 발급 방식을 권장합니다. - - + {t('codexGuide.codex.step1.desc')} + + + {t('codexGuide.codex.step2.title')} + + {t('codexGuide.codex.step2.proxy')} + {t('codexGuide.codex.step2.expired')} + + - + )} - {/* Tab 2: Other AI STT Providers */} + {/* 다른 STT 서비스 키 */} {tab === 2 && ( - - - D3RO-Voice는 최신 고성능 AI STT 엔진을 폭넓게 지원합니다. 원하는 서비스의 무료 키를 발급받아 등록하세요: + + + {t('codexGuide.providers.intro')} - - - - - - Groq STT - - - - LPU 칩셋 기반으로 Whisper Large V3 모델을 0.2초만에 처리합니다. - - - - - - - - - Deepgram Nova-3 - + + {provider.name} + {t(provider.descriptionKey)} - - 스마트 구두점 및 포맷팅 지원. 가입 시 무료 크레딧 제공. - + - - - - - - - Google Gemini 2.0 Flash Audio - - - - Google AI Studio에서 무료 API 키를 발급받아 사용할 수 있습니다. - - - - - - - - - AssemblyAI Universal-2 - - - - 대화형 문맥 이해 및 음성 오디오 인텔리전스 엔진. - - - - - - + ))} + + )} - - - diff --git a/apps/desktop/src/renderer/components/FileDropZone.tsx b/apps/desktop/src/renderer/components/FileDropZone.tsx index ce32a0c..6c56c9a 100644 --- a/apps/desktop/src/renderer/components/FileDropZone.tsx +++ b/apps/desktop/src/renderer/components/FileDropZone.tsx @@ -2,10 +2,10 @@ // Phase 12.1: 파일 전사 드래그앤드롭 UI import { useState, useEffect, useCallback, useRef } from 'react' -import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material' +import { Box, ButtonBase, LinearProgress, IconButton, Tooltip } from '@mui/material' import { Copy, X, FileUp } from 'lucide-react' import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roTypo, d3roRadius } from '@d3ro/ui/theme' +import { d3roPalette, d3roTypo, d3roRadius, d3roFocusRing, d3roTransition } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { FileTranscriptionProgress, @@ -27,7 +27,7 @@ export function FileDropZone(): React.ReactElement { const [result, setResult] = useState(null) const [error, setError] = useState(null) const [copied, setCopied] = useState(false) - const dropRef = useRef(null) + const dropRef = useRef(null) useEffect(() => { const unsubProgress = window.electronAPI.fileTranscription.onProgress((data) => { @@ -152,32 +152,40 @@ export function FileDropZone(): React.ReactElement { // ── idle: 드래그 존 ── if (state === 'idle') { return ( - - { e.preventDefault(); setDragging(true) }} - onDragLeave={() => setDragging(false)} - onDrop={handleDrop} - onClick={handleBrowse} - sx={{ - p: 4, - textAlign: 'center', - border: `2px dashed ${dragging ? d3roPalette.accent.main : d3roPalette.border.subtle}`, - borderRadius: d3roRadius.small, - cursor: 'pointer', - transition: 'border-color 0.2s', - '&:hover': { borderColor: d3roPalette.accent.main }, - }} - > - - + // 끌어다 놓기 + 눌러서 고르기(키보드 Enter/Space 포함) — 버튼 하나가 둘 다 받는다 + { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => void handleBrowse()} + sx={{ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 2, + px: 3, + py: 2.5, + textAlign: 'left', + border: `1px dashed ${dragging ? d3roPalette.accent.main : d3roPalette.border.strong}`, + borderRadius: d3roRadius.card, + bgcolor: dragging ? d3roPalette.accent.dim : 'transparent', + transition: d3roTransition('border-color', 'background-color'), + '&:hover': { borderColor: d3roPalette.accent.main }, + '&:focus-visible': d3roFocusRing, + }} + > + + + {t('fileTranscription.dropZone')} - + {t('fileTranscription.dropZoneHint')} - + ) } diff --git a/apps/desktop/src/renderer/components/LicenseModal.tsx b/apps/desktop/src/renderer/components/LicenseModal.tsx index 86d446d..e258115 100644 --- a/apps/desktop/src/renderer/components/LicenseModal.tsx +++ b/apps/desktop/src/renderer/components/LicenseModal.tsx @@ -1,21 +1,13 @@ // src/renderer/components/LicenseModal.tsx -// 구독 기반 라이선스 모달 — 클라우드 인증 + Payple 결제 연동 +// 구독 기반 라이선스 모달 — 설정 > 라이선스 탭과 같은 구획(요금제 · 사용량 · 등급 비교)을 대화상자로 보여 준다. +// v4(2026-09-28): MUI Dialog 기본 표면 + 제목 h2 + 본문. 장식 LED·카드 중첩 제거. -import { - Dialog, - DialogTitle, - DialogContent, - Box, - IconButton, - Divider, - LinearProgress, -} from '@mui/material' -import { X, CircleCheck, XCircle } from 'lucide-react' -import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roTypo, d3roRadius, d3roShadow, d3roFontMono } from '@d3ro/ui/theme' +import { Dialog, DialogTitle, DialogContent, Box, IconButton } from '@mui/material' +import { X } from 'lucide-react' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' -import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants' import { useLicenseState } from '../hooks/useLicenseState' +import { LicenseComparisonSection, LicensePlanSection, LicenseUsageSection } from './LicenseTab' interface LicenseModalProps { open: boolean @@ -25,8 +17,8 @@ interface LicenseModalProps { export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement { const { t } = useI18n() const { - cloud, usage, comparison, - currentTier, isFree, isPro, + licenseInfo, cloud, usage, comparison, + currentTier, handleUpgrade, handleOpenBilling, openCloudSettings, } = useLicenseState() @@ -36,311 +28,45 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE onClose={onClose} maxWidth="sm" fullWidth - PaperProps={{ - sx: { - bgcolor: d3roPalette.bg.app, - backgroundImage: 'none', - borderRadius: d3roRadius.card, - border: `1px solid ${d3roPalette.border.subtle}`, - boxShadow: d3roShadow.card, - maxHeight: '85vh', - }, - }} + aria-labelledby="license-dialog-title" + PaperProps={{ sx: { maxHeight: '85vh' } }} > - {t('license.title')} - + {t('license.title')} + - - {/* ── 현재 티어 ── */} - - - - - {t('license.currentTier')} - - - {cloud.authenticated && cloud.userEmail && ( - - {cloud.userEmail} - - )} - - - - {/* ── 구독 관리 ── */} - - + + { onClose(); openCloudSettings() }} /> - - - {/* ── 사용량 ── */} - {usage.length > 0 && ( - - {t('license.dailyUsage')} - {usage.map((q) => ( - - - - {t(`license.feature.${q.feature}` as Parameters[0])} - - - {q.limit === -1 - ? t('license.unlimited') - : t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })} - - - {q.limit > 0 && ( - = q.limit ? d3roPalette.tag.red : d3roPalette.accent.main, - borderRadius: d3roRadius.xs, - }, - }} - /> - )} - - ))} - - {/* Premium 모델별 쿼터 */} - {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && ( - <> - - - {t('license.premiumQuota')} - - {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => ( - - - {t(m.i18nKey as Parameters[0])} - - {m.limit === -1 - ? t('license.unlimited') - : `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`} - - - {m.limit > 0 && ( - - )} - - ))} - - )} - - )} - - {/* ── 티어 비교표 ── */} - {comparison.length > 0 && ( - - {t('license.tierComparison')} - - - - {''} - {t('license.free')} - {t('license.pro')} - {t('license.proPlus')} - - - - {comparison.map((row) => ( - - {t(row.featureLabel as Parameters[0])} - - - - - ))} - - - - )} + + + ) } - -// ── 공유 하위 컴포넌트 ────────────────────────────────── - -function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement { - 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 {label} -} - -interface SubscriptionSectionProps { - t: (k: string) => string - authenticated: boolean - isFree: boolean - isPro: boolean - onUpgrade: (tier: 'pro' | 'pro_plus') => void - onManage: () => void - onSignIn: () => void -} - -function SubscriptionSection({ t, authenticated, isFree, isPro, onUpgrade, onManage, onSignIn }: SubscriptionSectionProps): React.ReactElement { - if (!authenticated) { - return ( - - - {t('license.signInRequired')} - - - {t('license.signInToUpgrade')} - - - ) - } - - if (isFree) { - return ( - - {t('license.subscribe')} - - {t('license.upgradeToProDesc')} - - - - - ) - } - - if (isPro) { - return ( - - - - - {t('license.subscriptionManage')} - - - ) - } - - return ( - - - - {t('license.subscriptionManage')} - - - ) -} - -function ActivePlanIndicator({ label, color }: { label: string; color: string }): React.ReactElement { - return ( - - - {label} - - ) -} - -function UpgradeButton({ tier, t, onUpgrade }: { tier: 'pro' | 'pro_plus'; t: (k: string) => string; onUpgrade: (t: 'pro' | 'pro_plus') => void }): React.ReactElement { - const planLabel = tier === 'pro_plus' ? t('license.proPlusPlan') : t('license.proPlan') - return ( - onUpgrade(tier)} sx={{ width: '100%' }}> - - - - {t('license.upgrade')} — {planLabel} - - - - ) -} - -function TierCell({ value }: { value: boolean | string }): React.ReactElement { - if (value === true) { - return - } - if (value === false) { - return - } - return ( - - {value} - - ) -} diff --git a/apps/desktop/src/renderer/components/LicenseTab.tsx b/apps/desktop/src/renderer/components/LicenseTab.tsx index b8966c4..62d0a4d 100644 --- a/apps/desktop/src/renderer/components/LicenseTab.tsx +++ b/apps/desktop/src/renderer/components/LicenseTab.tsx @@ -1,450 +1,374 @@ +// src/renderer/components/LicenseTab.tsx +// 설정 > 라이선스 탭 본문. v4(2026-09-28): 한 열 설정 행 문법 — 요금제 · 라이선스 키 · 사용량 · 등급 비교. +// 요금제·사용량·비교 구획은 LicenseModal 도 같이 쓴다(여기서 export). + import { useState } from 'react' -import { - Box, - Divider, - LinearProgress, - TextField, -} from '@mui/material' -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 { Box, Button, LinearProgress, TextField, Typography } from '@mui/material' +import { Check, Minus } from 'lucide-react' +import { Led } from '@d3ro/ui/components/ds' +import { d3roPalette, d3roFontMono, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import type { TFunction } from '@d3ro/i18n' import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants' +import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@d3ro/core/types' import { useLicenseState } from '../hooks/useLicenseState' +import { featureLabelKey, tierLabelKey } from '../utils/display-labels' +import { asTranslationKey } from './keybinding/translation-key' +import { SettingNote, SettingRow, SettingsSection } from './settings/SettingsRows' + +const DAY_MS = 24 * 60 * 60 * 1000 export function LicenseTab(): React.ReactElement { const { t } = useI18n() const { licenseInfo, usage, comparison, cloud, - currentTier, isFree, isPro, + currentTier, handleUpgrade, handleOpenBilling, openCloudSettings, } = useLicenseState() return ( - - {/* ── 현재 플랜 + 계정 상태 ── */} - - - - - - {t('license.currentTier')} - - - - {cloud.authenticated && cloud.userEmail && ( - - {cloud.userEmail} - - )} - - - - {/* ── 14일 Reverse-Trial 상태 배너 (활성화 시) ── */} - {licenseInfo?.isTrial && licenseInfo.trialExpiresAt && ( - - - - - - 14-Day Reverse Trial (Pro+) - - - {Math.max(0, Math.ceil((licenseInfo.trialExpiresAt - Date.now()) / (24 * 60 * 60 * 1000)))}일 남음 — 만료 시 100% 로컬 무료 모드로 안전하게 전환됩니다. - - - - - )} - - {/* ── 구독 관리 / 업그레이드 ── */} - - - - - {/* ── 오프라인 라이선스 키 등록 ── */} - - - - - - - {/* ── 일일 사용량 ── */} - - - {/* ── 티어 비교표 ── */} - + + + + + ) } -// ── 하위 컴포넌트 ────────────────────────────────────── +// ── 공유 구획 (LicenseModal 도 쓴다) ───────────────────────── -function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement { - 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 {label} +function planNameKey(tier: LicenseTier): Parameters[0] { + switch (tier) { + case 'pro': return 'license.proPlan' + case 'pro_plus': return 'license.proPlusPlan' + case 'team': return 'license.teamPlan' + case 'enterprise': return 'license.enterprisePlan' + default: return 'license.freePlan' + } } -function LicenseKeySection({ currentTier }: { currentTier: string }): React.ReactElement { +interface LicensePlanSectionProps { + t: TFunction + licenseInfo: LicenseInfo | null + currentTier: LicenseTier + authenticated: boolean + userEmail: string | null + onUpgrade: (tier: 'pro' | 'pro_plus') => void + onManage: () => void + onSignIn: () => void +} + +export function LicensePlanSection({ + t, licenseInfo, currentTier, authenticated, userEmail, onUpgrade, onManage, onSignIn, +}: LicensePlanSectionProps): React.ReactElement { + const trialDaysLeft = licenseInfo?.isTrial && licenseInfo.trialExpiresAt + ? Math.max(0, Math.ceil((licenseInfo.trialExpiresAt - Date.now()) / DAY_MS)) + : null + // 상태 점: 무료 = 꺼짐, 체험 = 주의(끝나는 날이 있다), 유료 = 활성 + const ledColor = currentTier === 'free' ? 'off' : trialDaysLeft !== null ? 'orange' : 'green' + + return ( + + + + + {t(tierLabelKey(currentTier))} + + + } + /> + + {trialDaysLeft !== null && ( + + )} + + + + ) +} + +interface SubscriptionRowProps { + t: TFunction + currentTier: LicenseTier + authenticated: boolean + onUpgrade: (tier: 'pro' | 'pro_plus') => void + onManage: () => void + onSignIn: () => void +} + +/** + * 구독 행. 등급으로 직접 가른다 — 예전에는 isPro(= free 가 아니면 true)로 갈라서 + * Pro+·팀·엔터프라이즈 사용자에게도 "Pro 플랜 구독 중 + Pro+로 업그레이드"가 보였다. + */ +function SubscriptionRow({ t, currentTier, authenticated, onUpgrade, onManage, onSignIn }: SubscriptionRowProps): React.ReactElement { + if (!authenticated) { + return ( + + {t('license.signInToUpgrade')} + + } + /> + ) + } + + if (currentTier === 'free') { + return ( + + + + + } + /> + ) + } + + return ( + + {currentTier === 'pro' && ( + + )} + + + } + /> + ) +} + +function LicenseKeySection({ t, currentTier }: { t: TFunction; currentTier: LicenseTier }): React.ReactElement { const [licenseKeyInput, setLicenseKeyInput] = useState('') const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null) const [loading, setLoading] = useState(false) - const handleActivateKey = async () => { + const handleActivateKey = async (): Promise => { 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 || '라이센스가 성공적으로 활성화되었습니다.' }) + setFeedback({ success: true, message: res.data.message || t('license.key.activated') }) setLicenseKeyInput('') } else { - setFeedback({ success: false, message: (res.success && res.data.message) ? res.data.message : '유효하지 않은 라이센스 키입니다.' }) + setFeedback({ success: false, message: (res.success && res.data.message) ? res.data.message : t('license.key.invalid') }) } } catch (err) { - setFeedback({ success: false, message: `활성화 실패: ${err}` }) + setFeedback({ success: false, message: t('license.key.failed', { error: err instanceof Error ? err.message : String(err) }) }) } finally { setLoading(false) } } - const handleDeactivate = async () => { + const handleDeactivate = async (): Promise => { setLoading(true) try { await window.electronAPI.license.deactivate() - setFeedback({ success: true, message: '라이센스가 비활성화되어 Free 티어로 전환되었습니다.' }) + setFeedback({ success: true, message: t('license.key.deactivated') }) } finally { setLoading(false) } } return ( - - - - - 오프라인 라이센스 키 활성화 (Ed25519) - - - - - 발급받은 Ed25519 디지털 서명 라이센스 키 (D3RO-LIC-...)를 입력하세요. - - - + + { e.preventDefault(); void handleActivateKey() }} + sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', py: 1.25 }} + > setLicenseKeyInput(e.target.value)} disabled={loading} + inputProps={{ 'aria-label': t('license.key.title'), spellCheck: false }} 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 }, - }, + flex: '1 1 240px', + // 키 문자열은 라틴 코드라 모노로 둔다 + '& .MuiOutlinedInput-root': { fontFamily: d3roFontMono, ...typoSx('compact') }, }} /> - - 등록 - + {currentTier !== 'free' && ( - - 해제 - + )} - {feedback && ( - + {feedback.message} - + )} - + ) } -interface SubscriptionSectionProps { - t: (k: string) => string - authenticated: boolean - isFree: boolean - isPro: boolean - currentTier: string - onUpgrade: (tier: 'pro' | 'pro_plus') => void - onManage: () => void - onSignIn: () => void +interface LicenseUsageSectionProps { + t: TFunction + usage: UsageQuota[] + currentTier: LicenseTier } -function SubscriptionSection({ t, authenticated, isFree, isPro, onUpgrade, onManage, onSignIn }: SubscriptionSectionProps): React.ReactElement { - if (!authenticated) { - return ( - - - {t('license.signInRequired')} - - - {t('license.signInToUpgrade')} - - - ) - } - - if (isFree) { - return ( - - - {t('license.subscribe')} - - - {t('license.upgradeToProDesc')} - - - - - ) - } - - if (isPro) { - return ( - - - - - {t('license.subscriptionManage')} - - - ) - } - - if (currentTier === 'team') { - return ( - - - - {t('license.subscriptionManage')} - - - ) - } - - if (currentTier === 'enterprise') { - return ( - - - - {t('license.subscriptionManage')} - - - ) - } - - // Pro+ - return ( - - - - {t('license.subscriptionManage')} - - - ) -} - -function ActivePlanIndicator({ label, color }: { label: string; color: string }): React.ReactElement { - return ( - - - {label} - - ) -} - -function UpgradeButton({ tier, t, onUpgrade }: { tier: 'pro' | 'pro_plus'; t: (k: string) => string; onUpgrade: (t: 'pro' | 'pro_plus') => void }): React.ReactElement { - const planLabel = tier === 'pro_plus' ? t('license.proPlusPlan') : t('license.proPlan') - return ( - onUpgrade(tier)} sx={{ width: '100%' }}> - - - - {t('license.upgrade')} — {planLabel} - - - - ) -} - -function UsageSection({ t, usage, currentTier }: { t: (k: string) => string; usage: Array<{ feature: string; used: number; limit: number; remaining: number }>; currentTier: string }): React.ReactElement | null { +export function LicenseUsageSection({ t, usage, currentTier }: LicenseUsageSectionProps): React.ReactElement | null { if (usage.length === 0) return null + const premium = PREMIUM_MODEL_LIMITS[currentTier] ?? [] return ( - <> - - {t('license.dailyUsage')} - - + {usage.map((q) => ( - + ))} - {/* Premium 모델별 쿼터 */} - {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && ( + {premium.length > 0 && ( <> - + {t('license.premiumQuota')} - - {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => ( - - - {t(m.i18nKey)} - + + {/* 모델별 사용 건수는 아직 받지 못한다 — 한도만 보여 주고, 늘 0% 인 막대는 그리지 않는다 */} + {premium.map((m) => ( + {m.limit === -1 ? t('license.unlimited') - : `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`} - - - {m.limit > 0 && ( - - )} - + : t(m.period === 'weekly' ? 'license.quotaPerWeek' : 'license.quotaPerDay', { limit: m.limit })} + + } + /> ))} )} - - - + ) } -function QuotaBar({ t, feature, used, limit, remaining }: { t: (k: string, p?: Record) => string; feature: string; used: number; limit: number; remaining: number }): React.ReactElement { +export function QuotaRow({ t, quota }: { t: TFunction; quota: UsageQuota }): React.ReactElement { + const { used, limit, remaining } = quota + const exhausted = limit > 0 && remaining === 0 return ( - - - - {t(`license.feature.${feature}`)} - - - {limit === -1 - ? t('license.unlimited') - : t('license.quotaUsed', { used: String(used), limit: String(limit) })} - - + + {limit === -1 ? t('license.unlimited') : t('license.quotaUsed', { used, limit })} + + } + > {limit > 0 && ( = limit ? d3roPalette.tag.red : d3roPalette.accent.main, + bgcolor: used >= limit ? d3roPalette.status.danger : d3roPalette.text.secondary, borderRadius: d3roRadius.xs, }, }} /> )} - + ) } -function ComparisonTable({ t, comparison }: { t: (k: string) => string; comparison: Array<{ feature: string; featureLabel: string; free: boolean | string; pro: boolean | string; proPlus: boolean | string }> }): React.ReactElement | null { +export function LicenseComparisonSection({ t, comparison }: { t: TFunction; comparison: TierComparison[] }): React.ReactElement | null { if (comparison.length === 0) return null return ( - <> - - {t('license.tierComparison')} - - + - {''} - {t('license.free')} - {t('license.pro')} - {t('license.proPlus')} + {t('license.tierComparison')} + {t('license.free')} + {t('license.pro')} + {t('license.proPlus')} {comparison.map((row) => ( - {t(row.featureLabel)} + {t(featureLabelKey(row.feature))} @@ -452,20 +376,29 @@ function ComparisonTable({ t, comparison }: { t: (k: string) => string; comparis ))} - + ) } +const visuallyHidden = { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', +} as const + function TierCell({ value }: { value: boolean | string }): React.ReactElement { if (value === true) { - return + return } if (value === false) { - return + return } return ( - + {value} - + ) } diff --git a/apps/desktop/src/renderer/components/OllamaGuideModal.tsx b/apps/desktop/src/renderer/components/OllamaGuideModal.tsx index e7d03a1..deff26c 100644 --- a/apps/desktop/src/renderer/components/OllamaGuideModal.tsx +++ b/apps/desktop/src/renderer/components/OllamaGuideModal.tsx @@ -1,5 +1,6 @@ // src/renderer/components/OllamaGuideModal.tsx -// Ollama 설치/설정/모델 다운로드 종합 인터랙티브 모달 (v2 Midnight Glass) +// Ollama 설치·실행·모델 받기·동작 확인 안내 모달. AppLayout 이 'd3ro:open-ollama-guide' 이벤트로 연다. +// v4(2026-09-28): 평면 다이얼로그 — 제목(h2) + 번호 붙은 소제목 3개 + 1px 선. 연결 상태는 실제 조회 값만 쓴다. import { useState, useEffect, useCallback } from 'react' import { @@ -10,38 +11,18 @@ import { Box, Typography, Button, - Divider, IconButton, Tooltip, - Paper, - Chip, LinearProgress, TextField, CircularProgress, Alert, } from '@mui/material' -import { - X, - ExternalLink, - Copy, - Check, - RefreshCw, - Play, - Download, - CheckCircle2, - Zap, -} from 'lucide-react' -import { - d3roPalette, - d3roFontSans, - d3roFontMono, - d3roTypo, - d3roShadow, - d3roRadius, - typoSx, -} from '@d3ro/ui/theme' -import { Led } from '@d3ro/ui/components/ds' +import { X, ExternalLink, Copy, Check, RefreshCw, Play, Download, CheckCircle2 } from 'lucide-react' +import { d3roPalette, d3roFontMono, d3roNumeralSx, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { Led, TactileBadge } from '@d3ro/ui/components/ds' import { useI18n } from '@d3ro/i18n' +import type { TranslationKey } from '@d3ro/i18n' import type { LLMModel, LLMStatus } from '@d3ro/core/types' import { useOllamaModelPull } from '../hooks/useOllamaModelPull' @@ -53,39 +34,21 @@ export interface OllamaGuideModalProps { interface RecommendedModel { id: string name: string + /** 대략의 다운로드 크기 (라틴 수치) */ size: string - description: string + descriptionKey: TranslationKey recommended?: boolean } -const RECOMMENDED_MODELS: RecommendedModel[] = [ - { - id: 'gemma4:e4b', - name: 'Gemma 4 (E4B)', - size: '약 4 GB', - description: '이 앱의 기본 로컬 모델. 추론 토큰을 쓰지 않아 받아쓰기 다듬기에 가장 빠름 (기본 추천)', - 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: 'phi4', - name: 'Phi 4', - size: '9.1 GB', - description: '요약·번역 품질이 높음 (RAM 16GB+ 권장)', - }, +const RECOMMENDED_MODELS: readonly RecommendedModel[] = [ + { id: 'gemma4:e4b', name: 'Gemma 4 (E4B)', size: '4 GB', descriptionKey: 'ollamaGuide.model.gemma4e4b', recommended: true }, + { id: 'llama3.2:3b', name: 'Llama 3.2 (3B)', size: '2.0 GB', descriptionKey: 'ollamaGuide.model.llama32' }, + { id: 'qwen2.5:3b', name: 'Qwen 2.5 (3B)', size: '1.9 GB', descriptionKey: 'ollamaGuide.model.qwen25' }, + { id: 'phi4', name: 'Phi 4', size: '9.1 GB', descriptionKey: 'ollamaGuide.model.phi4' }, ] +const DEFAULT_SERVER_URL = 'http://localhost:11434' + function CodeBlock({ code }: { code: string }): React.ReactElement { const { t } = useI18n() const [copied, setCopied] = useState(false) @@ -100,41 +63,28 @@ function CodeBlock({ code }: { code: string }): React.ReactElement { {code} - + {copied ? : } @@ -143,6 +93,14 @@ function CodeBlock({ code }: { code: string }): React.ReactElement { ) } +function StepHeading({ children }: { children: React.ReactNode }): React.ReactElement { + return ( + + {children} + + ) +} + export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement { const { t } = useI18n() const [llmStatus, setLlmStatus] = useState(null) @@ -153,8 +111,9 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac const [startMessage, setStartMessage] = useState(null) // Test prompt state - const [testPrompt, setTestPrompt] = useState('안녕하세요 오늘의 날씨를 알려줘') + const [testPrompt, setTestPrompt] = useState(() => t('ollamaGuide.test.sample')) const [testResult, setTestResult] = useState(null) + const [testFailed, setTestFailed] = useState(false) const [testing, setTesting] = useState(false) const refreshStatus = useCallback(async () => { @@ -210,20 +169,20 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac const res = await window.electronAPI.llm.startServer() if (res.success) { if (res.data === 'running') { - setStartMessage('Ollama가 이미 실행 중입니다.') + setStartMessage(t('ollamaGuide.start.running')) } else if (res.data === 'starting') { - setStartMessage('Ollama 서버를 시작했습니다. 연결 확인 중...') + setStartMessage(t('ollamaGuide.start.starting')) setTimeout(() => refreshStatus(), 2000) } else if (res.data === 'not-installed') { - setStartMessage('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.') + setStartMessage(t('ollamaGuide.start.notInstalled')) } else { - setStartMessage('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.') + setStartMessage(t('ollamaGuide.start.failed')) } } } finally { setStarting(false) } - }, [refreshStatus]) + }, [refreshStatus, t]) const handlePullModel = useCallback(async (modelId: string) => { await pull(modelId) @@ -238,6 +197,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac if (!testPrompt.trim()) return setTesting(true) setTestResult(null) + setTestFailed(false) try { const res = await window.electronAPI.llm.process({ text: testPrompt, @@ -246,95 +206,48 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac if (res.success && res.data) { setTestResult(res.data.processedText) } else { - setTestResult(`오류: ${res.success ? '응답 실패' : res.error.message}`) + setTestFailed(true) + setTestResult(res.success ? t('ollamaGuide.test.noResponse') : t('ollamaGuide.test.error', { message: res.error.message })) } } catch (err) { - setTestResult(`실패: ${err instanceof Error ? err.message : String(err)}`) + setTestFailed(true) + setTestResult(t('ollamaGuide.test.error', { message: err instanceof Error ? err.message : String(err) })) } finally { setTesting(false) } - }, [testPrompt]) + }, [testPrompt, t]) const isConnected = llmStatus?.connectionState === 'connected' + const sectionSx = { display: 'flex', flexDirection: 'column', gap: 1.25, pt: 2.5, borderTop: `1px solid ${d3roPalette.border.subtle}` } as const return ( - + - - - - Ollama 로컬 AI 설정 & 모델 관리 - - - + {t('ollamaGuide.title')} + - - {/* ── 실시간 상태 바 ── */} - - - - - + + {/* ── 연결 상태 ── */} + + + + + {isConnected - ? `Ollama 연결 완료 ${llmStatus?.serverVersion ? `(v${llmStatus.serverVersion})` : ''}` - : 'Ollama 오프라인 (서버 실행 필요)'} + ? llmStatus?.serverVersion + ? t('ollamaGuide.status.connectedVersion', { version: llmStatus.serverVersion }) + : t('ollamaGuide.status.connected') + : t('ollamaGuide.status.disconnected')} - - 서버 URL: {llmStatus?.serverUrl || 'http://localhost:11434'} | 활성 모델: {activeModel || '—'} + + {t('ollamaGuide.status.detail', { url: llmStatus?.serverUrl || DEFAULT_SERVER_URL, model: activeModel || '—' })} @@ -346,36 +259,16 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac startIcon={} onClick={refreshStatus} disabled={checking} - sx={{ - fontFamily: d3roFontMono, - fontSize: '11px', - borderColor: d3roPalette.glass.hairlineStrong, - color: d3roPalette.text.primary, - }} > - 연결 확인 + {t('ollamaGuide.recheck')} {!isConnected && ( - )} - + {startMessage && ( @@ -383,283 +276,189 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac )} - {/* ── Step 1: Ollama 설치 및 기동 ── */} - - - - - Ollama 설치 및 백그라운드 실행 - - - - Ollama는 PC 내에서 100% 무료, 오프라인으로 LLM을 구동하는 오픈소스 엔진입니다. + {/* ── 1. 설치와 실행 ── */} + + {t('ollamaGuide.step1.title')} + + {t('ollamaGuide.step1.desc')} - - + - - - - {/* ── Step 2: 추천 모델 다운로드 ── */} - - - - - - 한국어 최적화 추천 모델 1-클릭 다운로드 - - - - 설치된 모델: {installedModels.length}개 + {/* ── 2. 모델 받기 ── */} + + + {t('ollamaGuide.step2.title')} + + {t('ollamaGuide.step2.installedCount', { count: installedModels.length })} - - - 음성 인식 후 텍스트 다듬기, 번역, 요약에 가장 안정적인 경량 AI 모델들입니다. + + {t('ollamaGuide.step2.desc')} {pullError && ( - + {pullError.stage === 'pull' ? `${pullError.modelId} — ${t('onboarding.failed', { message: pullError.message })}` : `${pullError.modelId} — ${pullError.message}`} )} - {/* 모델 카드 그리드 */} - + {/* 모델 목록 — 한 열, 행마다 1px 선 */} + {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 ( - - - - - + + + + {model.name} - {model.recommended && ( - + + {model.size} + + {model.recommended && {t('ollamaGuide.model.recommended')}} + + + {t(model.descriptionKey)} + + + + {!isDownloading && ( + + {isInstalled ? ( + isActive ? ( + + {t('ollamaGuide.model.inUse')} + + ) : ( + + ) + ) : ( + )} - - - - {model.description} - + )} - {/* 다운로드 진행률 또는 액션 버튼 */} - {isDownloading ? ( + {isDownloading && ( - + {pullProgress.status || t('onboarding.statusPreparing')} - + {pullProgress.percent}% - - - ) : ( - - {isInstalled ? ( - - ) : ( - - )} + )} - + ) })} {/* 터미널 명령어 수동 입력 안내 */} - - - 직접 터미널에서 다운로드하려면 아래 명령어를 실행하세요: - - - + + {t('ollamaGuide.terminalHint')} + + - + {/* ── 3. 동작 확인 ── */} + + {t('ollamaGuide.step3.title')} + + {t('ollamaGuide.step3.desc')} + - {/* ── Step 3: 실시간 연결 및 테스트 ── */} - - - - - 동작 테스트 (AI 문장 정제) - - - - + { + e.preventDefault() + void handleRunTest() + }} + sx={{ display: 'flex', gap: 1, alignItems: 'center' }} + > setTestPrompt(e.target.value)} - placeholder="테스트할 음성 전사 문장 입력" - sx={{ - '& .MuiOutlinedInput-root': { - fontSize: '12px', - bgcolor: d3roPalette.bg.inset, - }, - }} + placeholder={t('ollamaGuide.test.placeholder')} + inputProps={{ 'aria-label': t('ollamaGuide.test.inputLabel') }} /> {testResult && ( - - - AI 정제 결과 ({activeModel}): - - + + {!testFailed && ( + + {t('ollamaGuide.test.result', { model: activeModel })} + + )} + {testResult} - + )} - - - D3RO Voice는 인터넷 없이도 로컬에서 100% 프라이빗하게 작동합니다. + + + {t('ollamaGuide.footer')} - diff --git a/apps/desktop/src/renderer/components/OnboardingModal.tsx b/apps/desktop/src/renderer/components/OnboardingModal.tsx index ffa0014..169e16b 100644 --- a/apps/desktop/src/renderer/components/OnboardingModal.tsx +++ b/apps/desktop/src/renderer/components/OnboardingModal.tsx @@ -1,5 +1,7 @@ // src/renderer/components/OnboardingModal.tsx -// 첫 실행 온보딩 모달 — 사용 방식 선택 (로컬 Ollama 독립 모드 vs 온라인 클라우드 API) +// 첫 실행 온보딩 — 3단계: ① 사용 방식 선택(로컬 Ollama / 온라인 계정) ② 준비(Ollama 연결·모델 또는 로그인) ③ 완료. +// v4(2026-09-28): 평면 다이얼로그 + 단계 표시(n/3, 진행 막대) + "나중에 설정" 건너뛰기. +// 첫 실행은 Esc·배경 클릭으로 닫히지 않는다(onboarding-dismiss 정책) — 대신 명시적인 건너뛰기 버튼이 있다. import { useState, useEffect, useCallback } from 'react' import { @@ -14,45 +16,112 @@ import { TextField, Tab, Tabs, - Paper, - Chip, Alert, } from '@mui/material' -import { - CheckCircle2, - AlertCircle, - Cloud, - Download, - Play, - RefreshCw, - ExternalLink, - Cpu, - Sparkles, - ArrowRight, - ShieldCheck, -} from 'lucide-react' -import { d3roPalette, d3roRadius, d3roShadow, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme' -import { Led } from '@d3ro/ui/components/ds' +import { CheckCircle2, Cloud, Download, Play, RefreshCw, ExternalLink, Cpu, Check } from 'lucide-react' +import { d3roPalette, d3roRadius, d3roNumeralSx, d3roTransition, typoSx } from '@d3ro/ui/theme' +import { Led, TactileBadge } from '@d3ro/ui/components/ds' import { useI18n } from '@d3ro/i18n' +import type { TranslationKey } from '@d3ro/i18n' import type { LLMModel, LLMStatus } from '@d3ro/core/types' import { useOllamaModelPull } from '../hooks/useOllamaModelPull' +import { canDismissOnboarding } from './onboarding-dismiss' +import type { OnboardingPhase } from './onboarding-dismiss' -type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed' +type Phase = OnboardingPhase interface OnboardingModalProps { open: boolean onClose: () => void } +/** 첫 실행에서 받는 기본 로컬 모델 */ +const DEFAULT_LOCAL_MODEL = 'gemma4:e4b' +const AUTH_FORM_ID = 'onboarding-auth-form' +const TOTAL_STEPS = 3 + +const STEP_OF_PHASE: Readonly> = { + select_mode: 1, + local_ollama_setup: 2, + online_auth: 2, + failed: 2, + success: 3, +} + +const LOCAL_POINTS: readonly TranslationKey[] = [ + 'onboarding.mode.local.point1', + 'onboarding.mode.local.point2', + 'onboarding.mode.local.point3', +] + +const ONLINE_POINTS: readonly TranslationKey[] = [ + 'onboarding.mode.online.point1', + 'onboarding.mode.online.point2', + 'onboarding.mode.online.point3', +] + +interface ModeCardProps { + icon: React.ReactNode + title: string + badge?: string + description: string + points: readonly string[] + action: React.ReactNode + onSelect: () => void +} + +/** 사용 방식 카드 — 카드 전체를 눌러도, 안의 버튼(키보드 포커스·Enter)으로도 고를 수 있다. */ +function ModeCard({ icon, title, badge, description, points, action, onSelect }: ModeCardProps): React.ReactElement { + return ( + + + + {icon} + + {title} + + {badge && {badge}} + + {description} + + {points.map((point) => ( + + + {point} + + ))} + + + {action} + + ) +} + export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { const { t } = useI18n() const [internalOpen, setInternalOpen] = useState(false) const [phase, setPhase] = useState('select_mode') + // 이미 온보딩을 마친 사용자면 모달을 언제든 닫을 수 있다(첫 실행만 진행을 강제). + const [onboardingCompleted, setOnboardingCompleted] = useState(false) // Local Ollama State const [ollamaStatus, setOllamaStatus] = useState(null) const [installedModels, setInstalledModels] = useState([]) - const [activeModel, setActiveModel] = useState('gemma4:e4b') + const [activeModel, setActiveModel] = useState(DEFAULT_LOCAL_MODEL) const [checkingOllama, setCheckingOllama] = useState(false) const [startingOllama, setStartingOllama] = useState(false) const [ollamaMsg, setOllamaMsg] = useState(null) @@ -64,7 +133,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. const [authError, setAuthError] = useState('') const [authLoading, setAuthLoading] = useState(false) - // Error State + // Error State — 'failed' 단계는 현재 진입 경로가 없다(호환용으로 유지) const errorMsg = '' const isVisible = open || internalOpen @@ -87,7 +156,9 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. useEffect(() => { window.electronAPI.config.getAll().then((res) => { - if (res.success && !res.data.onboardingCompleted) { + if (!res.success) return + setOnboardingCompleted(res.data.onboardingCompleted) + if (!res.data.onboardingCompleted) { setInternalOpen(true) setPhase('select_mode') } @@ -132,14 +203,14 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. const res = await window.electronAPI.llm.startServer() if (res.success) { if (res.data === 'running') { - setOllamaMsg('Ollama가 이미 실행 중입니다.') + setOllamaMsg(t('ollamaGuide.start.running')) } else if (res.data === 'starting') { - setOllamaMsg('Ollama 서버를 시작했습니다. 연결 확인 중...') + setOllamaMsg(t('ollamaGuide.start.starting')) setTimeout(() => checkOllama(), 2000) } else if (res.data === 'not-installed') { - setOllamaMsg('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.') + setOllamaMsg(t('ollamaGuide.start.notInstalled')) } else { - setOllamaMsg('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.') + setOllamaMsg(t('ollamaGuide.start.failed')) } } } finally { @@ -173,10 +244,10 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) setPhase('success') } else { - setAuthError(res.error?.message ?? '인증에 실패하였습니다.') + setAuthError(res.error?.message ?? t('onboarding.auth.failed')) } } catch (err) { - setAuthError(`오류: ${err instanceof Error ? err.message : String(err)}`) + setAuthError(t('onboarding.auth.error', { message: err instanceof Error ? err.message : String(err) })) } finally { setAuthLoading(false) } @@ -187,220 +258,113 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. onClose() } + // 첫 실행 건너뛰기 — 기본값(로컬 모드)으로 시작하고 다시 묻지 않는다. 설정 › 정보에서 다시 열 수 있다. + // Ollama 도 온라인 계정도 없는 사용자가 첫 실행에서 갇히지 않게 하는 출구. + const handleSkip = async () => { + await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) + setOnboardingCompleted(true) + handleClose() + } + + const dismissible = canDismissOnboarding(phase, onboardingCompleted) + const isOllamaConnected = ollamaStatus?.connectionState === 'connected' const hasInstalledLlm = installedModels.length > 0 + const step = STEP_OF_PHASE[phase] + const stepLabel = t('onboarding.step', { current: step, total: TOTAL_STEPS }) if (!isVisible) return <> + const sectionBox = { + p: 2, + borderRadius: d3roRadius.inner, + border: `1px solid ${d3roPalette.border.subtle}`, + } as const + return ( {}} - maxWidth="md" + onClose={dismissible ? handleClose : () => {}} + maxWidth="sm" fullWidth - PaperProps={{ - sx: { - borderRadius: d3roRadius.card, - backgroundColor: d3roPalette.bg.card, - border: `1px solid ${d3roPalette.border.default}`, - p: 1, - }, - }} + aria-labelledby="onboarding-title" > - - {phase === 'success' ? ( - - ) : phase === 'failed' ? ( - - ) : ( - - )} - D3RO Voice 시작 가이드 + + {t('onboarding.wizard.title')} - - {/* ── 1. 사용 모드 선택 ── */} + {/* 단계 표시 — 숫자 + 진행 막대 */} + + + {stepLabel} + + + + + + {/* ── 1. 사용 방식 선택 ── */} {phase === 'select_mode' && ( - + - D3RO Voice를 어떤 환경으로 사용하시겠습니까? 언제든지 환경설정에서 변경할 수 있습니다. + {t('onboarding.mode.intro')} - - {/* 로컬 AI 독립 모드 (강력 권장) */} - - - - - - - 로컬 AI 모드 - - - - - - Ollama와 로컬 Whisper STT를 사용하여 PC 내부에서 완전히 독립 실행됩니다. - - - - {[ - '인터넷 연결 없이 오프라인 작동', - '음성/텍스트 데이터 외부 유출 제로 (완벽한 보안)', - 'API 비용 없이 무제한 전사 및 AI 다듬기', - ].map((item) => ( - - - - {item} - - - ))} - - - - - - - {/* 온라인 클라우드 모드 */} - 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, - }, - }} - > - - - - - - 온라인 클라우드 모드 - - - - - - 클라우드 서버 계정으로 로그인하여 AI 음성인식 및 고급 모델을 사용합니다. - - - - {[ - '로컬 모델 설치 불필요 (저사양 PC용)', - '클라우드 동기화 및 다중 기기 연동', - 'D3RO 계정 로그인 필요', - ].map((item) => ( - - - - {item} - - - ))} - - - - - + + } + title={t('onboarding.mode.local.title')} + badge={t('onboarding.mode.local.badge')} + description={t('onboarding.mode.local.desc')} + points={LOCAL_POINTS.map((key) => t(key))} + onSelect={handleSelectLocalMode} + action={ + // 첫 포커스 — Enter 로 권장 경로를 바로 시작한다. 클릭은 카드 onClick 으로 전파된다. + + } + /> + } + title={t('onboarding.mode.online.title')} + description={t('onboarding.mode.online.desc')} + points={ONLINE_POINTS.map((key) => t(key))} + onSelect={() => setPhase('online_auth')} + action={ + + } + /> )} - {/* ── 2. 로컬 Ollama 온보딩 스텝 ── */} + {/* ── 2a. 로컬 Ollama 준비 ── */} {phase === 'local_ollama_setup' && ( - - {/* 연결 상태 박스 */} - - - - - + + {/* 연결 상태 — 실제로 조회한 값만 쓴다 */} + + + + + {isOllamaConnected - ? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}` - : 'Ollama 로컬 엔진 확인 필요'} + ? ollamaStatus?.serverVersion + ? t('onboarding.local.connectedVersion', { version: ollamaStatus.serverVersion }) + : t('onboarding.local.connected') + : t('onboarding.local.disconnected')} - + {isOllamaConnected - ? `설치된 모델: ${installedModels.length}개 | 활성 모델: ${activeModel}` - : 'Ollama가 실행되어 있지 않거나 설치되지 않았습니다.'} + ? t('onboarding.local.connectedDetail', { count: installedModels.length, model: activeModel }) + : t('onboarding.local.disconnectedDetail')} @@ -412,24 +376,22 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. startIcon={} onClick={checkOllama} disabled={checkingOllama} - sx={{ fontSize: '11px' }} > - 연결 재확인 + {t('onboarding.local.recheck')} {!isOllamaConnected && ( )} - + {ollamaMsg && ( @@ -437,126 +399,120 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. )} - {/* Ollama 미설치 시 가이드 */} + {/* Ollama 미설치 시 안내 */} {!isOllamaConnected && ( - - - Ollama가 아직 설치되어 있지 않다면: + + + {t('onboarding.local.installTitle')} - - 1. 아래 버튼을 눌러 공식 웹사이트에서 Ollama를 다운로드하여 설치해 주세요.
- 2. 설치 후 '연결 재확인' 버튼을 누르면 D3RO Voice가 자동으로 감지합니다. + + {t('onboarding.local.installStep1')} + + + {t('onboarding.local.installStep2')}
)} - {/* 추천 모델 설치 섹션 */} + {/* 기본 모델 받기 */} - - 기본 한국어 AI 모델 설치 (Gemma 2 2B) + + {t('onboarding.local.modelTitle', { model: DEFAULT_LOCAL_MODEL })} - - 텍스트 정제, 요약, 번역에 최적화된 초경량 모델입니다. (크기: 약 1.6GB) + + {t('onboarding.local.modelDesc')} {pullingModel ? ( - + - + {pullProgress.status || t('onboarding.statusPreparing')} - + {pullProgress.percent}% - + ) : ( - {pullError && ( - - {pullError.stage === 'pull' - ? t('onboarding.failed', { message: pullError.message }) - : pullError.message} - - )} - - - - {hasInstalledLlm && ( - - 이미 로컬 AI 모델이 준비되어 있습니다. - + {pullError && ( + + {pullError.stage === 'pull' + ? t('onboarding.failed', { message: pullError.message }) + : pullError.message} + )} - + + + + {hasInstalledLlm && ( + + {t('onboarding.local.modelReady')} + + )} + )} - - - - + {!isOllamaConnected && ( + + {t('onboarding.local.finishHint')} + + )} )} - {/* ── 3. 온라인 계정 인증 ── */} + {/* ── 2b. 온라인 계정 로그인 ── */} {phase === 'online_auth' && ( - + - 온라인 서비스를 이용하기 위해 D3RO 계정으로 로그인해 주세요. + {t('onboarding.auth.intro')} setAuthTab(v)} + onChange={(_, v: 'login' | 'register') => setAuthTab(v)} sx={{ mb: 2, borderBottom: `1px solid ${d3roPalette.border.subtle}` }} > - - + + {authError && ( - + {authError} )} setEmail(e.target.value)} required @@ -564,57 +520,83 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. /> setPassword(e.target.value)} required - sx={{ mb: 3 }} /> - - - - - )} - {/* ── 4. 설정 완료 ── */} + {/* ── 3. 완료 ── */} {phase === 'success' && ( - - - D3RO Voice 설정이 완료되었습니다! + + + {t('onboarding.done.title')} - 언제든지 우측 상단 설정에서 STT 공급자 및 Ollama 모델을 변경할 수 있습니다. + {t('onboarding.done.desc')} )} {phase === 'failed' && ( - - - 설정 중 오류가 발생했습니다: {errorMsg} - - - + + {t('onboarding.failedPhase', { message: errorMsg })} + )}
- {phase === 'success' && ( - - - - )} + {/* ── 하단 행동: 왼쪽 = 뒤로·건너뛰기·닫기, 오른쪽 = 이 단계의 주 행동 하나 ── */} + + + {(phase === 'local_ollama_setup' || phase === 'online_auth') && ( + + )} + {phase !== 'success' && !dismissible && ( + + )} + {phase !== 'success' && dismissible && ( + + )} + + + + {phase === 'local_ollama_setup' && ( + + )} + {phase === 'online_auth' && ( + + )} + {phase === 'failed' && ( + + )} + {phase === 'success' && ( + + )} + +
) } diff --git a/apps/desktop/src/renderer/components/ProBadge.tsx b/apps/desktop/src/renderer/components/ProBadge.tsx index 3e7c595..4eb778a 100644 --- a/apps/desktop/src/renderer/components/ProBadge.tsx +++ b/apps/desktop/src/renderer/components/ProBadge.tsx @@ -1,10 +1,11 @@ // src/renderer/components/ProBadge.tsx // Feature gate badge: renders children normally if unlocked, // shows lock overlay with PRO badge if locked. +// v4(2026-09-28): 덮개는 키보드로도 누를 수 있는 버튼. 모노 글꼴·강조색 글자 제거 — 잠김은 강조가 아니다. -import { Box, Typography } from '@mui/material' +import { Box, ButtonBase, Typography } from '@mui/material' import { Lock } from 'lucide-react' -import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, d3roTransition, typoSx } from '@d3ro/ui/theme' import { useProFeature } from '../hooks/useProFeature' import { useI18n } from '@d3ro/i18n' import type { Feature } from '@d3ro/core/types' @@ -25,33 +26,27 @@ export function ProBadge({ feature, children }: ProBadgeProps): React.ReactEleme return ( - {/* Children rendered with reduced opacity */} - + {/* 잠긴 내용은 흐리게, 조작 불가 */} + {children} - {/* Lock overlay */} - - - + + {t('license.pro.required')} - + ) } diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx index 9425df9..8875fa3 100644 --- a/apps/desktop/src/renderer/components/STTTab.tsx +++ b/apps/desktop/src/renderer/components/STTTab.tsx @@ -1,47 +1,24 @@ // src/renderer/components/STTTab.tsx // STT 서비스 선택 및 다중 AI 공급자(OpenAI, Groq, Deepgram, AssemblyAI, Google 등) 연동 탭 +// v4(2026-09-28): 공급자 카드 그리드·카드 속 카드·아코디언 → 설정 행 한 열(settings/SettingsRows). 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 { HelpCircle, Eye, EyeOff, RefreshCw, HardDriveDownload, ChevronDown, ChevronUp } from 'lucide-react' +import { d3roPalette } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import type { TranslationKey } from '@d3ro/i18n' import type { AppConfig, STTProviderType, @@ -51,13 +28,33 @@ import type { } from '@d3ro/core/types' import { CodexOAuthGuideModal } from './CodexOAuthGuideModal' import { CaptionModelSelect } from './CaptionModelSelect' +import { SettingNote, SettingRow, SettingsSection, SETTING_CONTROL_WIDTH } from './settings/SettingsRows' import { subscribeSttTabEvents } from './stt/sttTabSubscriptions' +import { runModelDownload, releaseIfCurrent } from './stt/modelDownload' interface STTTabProps { config: Partial updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void } +const controlSx = { width: SETTING_CONTROL_WIDTH, maxWidth: '100%' } as const +const wideControlSx = { width: 320, maxWidth: '100%' } as const + +const PROVIDER_DESC_KEYS: Readonly> = { + local: 'settings.stt.provider.local.desc', + 'd3ro-cloud': 'settings.stt.provider.d3roCloud.desc', + openai: 'settings.stt.provider.openai.desc', + groq: 'settings.stt.provider.groq.desc', + deepgram: 'settings.stt.provider.deepgram.desc', + assemblyai: 'settings.stt.provider.assemblyai.desc', + google: 'settings.stt.provider.google.desc', + custom: 'settings.stt.provider.custom.desc', +} + +function toMegabytes(bytes: number): number { + return Math.round(bytes / 1_000_000) +} + export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElement { const { t } = useI18n() const [providers, setProviders] = useState([]) @@ -65,11 +62,13 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen const [providerConfig, setProviderConfig] = useState({}) const [showApiKey, setShowApiKey] = useState(false) const [guideOpen, setGuideOpen] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(false) // Local models const [localModels, setLocalModels] = useState([]) const [downloadingModelId, setDownloadingModelId] = useState(null) const [downloadPercent, setDownloadPercent] = useState(0) + const [downloadError, setDownloadError] = useState<{ modelId: string; message: string } | null>(null) // 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다 type RuntimeComponentName = 'sidecar' | 'ffmpeg' @@ -201,500 +200,360 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen }, [activeProvider, providerConfig]) const handleDownloadModel = useCallback(async (modelId: string) => { - setDownloadingModelId(modelId) - setDownloadPercent(0) - await window.electronAPI.stt.downloadModel({ modelId }) - }, []) + const release = (id: string): void => { + setDownloadingModelId((cur) => releaseIfCurrent(cur, id)) + setDownloadPercent(0) + } + await runModelDownload( + modelId, + { download: (id) => window.electronAPI.stt.downloadModel({ modelId: id }) }, + { + started: (id) => { + setDownloadError(null) + setDownloadingModelId(id) + setDownloadPercent(0) + }, + succeeded: (id) => { + release(id) + window.electronAPI.stt.getModels().then((res) => { + if (res.success && res.data) setLocalModels(res.data) + }) + }, + cancelled: release, + failed: (id, message) => { + release(id) + setDownloadError({ modelId: id, message: message || t('fileTranscription.error.unknown') }) + }, + }, + ) + }, [t]) const currentProviderInfo = providers.find((p) => p.id === activeProvider) + const providerName = currentProviderInfo?.name ?? activeProvider + // 공급자 설명은 렌더러 i18n 이 정본 — main 의 description/badge 는 한국어·마케팅 문구가 섞여 있어 쓰지 않는다. + const providerDescription = t(PROVIDER_DESC_KEYS[activeProvider]) + const needsApiKey = activeProvider !== 'd3ro-cloud' + const selectedModelId = config.sttModelId ?? 'large-v3-turbo' + const selectedModel = localModels.find((m) => m.id === selectedModelId) - return ( - - {/* ── 1. 공급자 선택 헤더 ── */} - - - - STT 음성 인식 서비스 선택 - + const renderModelDownload = (): React.ReactNode => { + if (!selectedModel || selectedModel.downloaded) return null + const isDownloading = downloadingModelId === selectedModel.id + const errorMessage = !isDownloading && downloadError?.modelId === selectedModel.id + ? downloadError.message + : null + return ( + + + + {t('settings.stt.modelNeedsDownload', { name: selectedModel.name, size: toMegabytes(selectedModel.sizeBytes) })} + - - {/* ── 공급자 카드 그리드 ── */} - - {providers.map((p) => { - const isSelected = p.id === activeProvider - return ( - handleProviderChange(p.id)} - sx={{ - p: 1.5, - cursor: 'pointer', - borderRadius: d3roRadius.small, - bgcolor: isSelected ? d3roPalette.bg.elevated : d3roPalette.bg.inset, - border: isSelected ? `1.5px solid ${d3roPalette.accent.main}` : `1px solid ${d3roPalette.border.subtle}`, - boxShadow: isSelected ? d3roShadow.card : '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, - }, - }} - > - - - {p.isCloud ? ( - p.id === 'groq' ? ( - - ) : p.id === 'openai' ? ( - - ) : ( - - ) - ) : ( - - )} - {isSelected && ( - - )} - - - {p.name} - - - - - ) - })} - + {errorMessage && ( + {errorMessage} + )} + ) + } - {/* ── 2. 선택된 공급자 빠른 설정 (Simple Mode) ── */} - + {/* ── 1. 엔진 ── */} + } onClick={() => setGuideOpen(true)}> + {t('settings.stt.openGuide')} + + } > - - - - {currentProviderInfo?.name} - - - {currentProviderInfo?.description} - - - - - - {/* Local Whisper 설정 */} - {activeProvider === 'local' ? ( - - - {t('settings.whisperModel')} + + } + /> + + {activeProvider === 'local' ? ( + <> + + + + } + > + {renderModelDownload()} + {/* 실시간 자막 전용 모델 — 1초마다 다시 인식하므로 받아쓰기와 다른 모델이 나을 수 있다 */} m.downloaded)} onChange={(modelId) => updateConfig('captionSttModelId', modelId)} /> - {/* 모델 다운로드 진행 바 또는 다운로드 버튼 */} - {(() => { - const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo')) - if (selected && !selected.downloaded) { - const isDownloading = downloadingModelId === selected.id - return ( - - - - {selected.name} 모델 다운로드가 필요합니다 - - - 크기: 약 {Math.round(selected.sizeBytes / 1_000_000)} MB - - - - - ) - } - return null - })()} - {/* 로컬 AI 런타임(엔진/ffmpeg): 설치본에는 없고 처음 필요할 때 내려받는다 */} {runtimeStatus.map((row) => { const busy = runtimeBusy === row.component return ( - - - - {row.component === 'sidecar' - ? '로컬 음성 엔진 (faster-whisper)' - : '미디어 변환기 (ffmpeg)'} - - - {row.installed - ? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB` - : busy - ? `다운로드 중 (${runtimePercent}%)` - : '설치되지 않음 — 로컬 전사에 필요합니다'} - - - {busy ? ( - - ) : ( - - )} - + label={row.component === 'sidecar' ? t('settings.stt.runtime.sidecar') : t('settings.stt.runtime.ffmpeg')} + description={ + busy + ? t('settings.stt.downloading', { percent: runtimePercent }) + : row.installed + ? t('settings.stt.runtime.installed', { size: toMegabytes(row.sizeBytes) }) + : t('settings.stt.runtime.missing') + } + control={ + busy ? ( + + ) : ( + + ) + } + /> ) })} - + ) : ( - /* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */ - - - handleProviderConfigFieldChange('apiKey', e.target.value)} - placeholder={ - activeProvider === 'openai' - ? 'sk-proj-...' - : activeProvider === 'groq' - ? 'gsk_...' - : 'API Key를 입력하세요' - } - fullWidth - size="small" - InputProps={{ - startAdornment: , - endAdornment: ( - - setShowApiKey((v) => !v)} edge="end"> - {showApiKey ? : } - - - ), - }} - /> - - - - {/* Test result feedback banner */} - {testResult && ( - : } - 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} - - )} - - )} - - - {/* ── 3. 전문가 상세 설정 (Accordion - Collapsed by default) ── */} - - } - sx={{ - minHeight: 40, - py: 0.5, - '& .MuiAccordionSummary-content': { my: 0.5 }, - }} - > - - 전문가 상세 설정 (엔드포인트, 커스텀 모델, 폴백 옵션) - - - - - - - - {/* Custom Model ID */} - handleProviderConfigFieldChange('modelId', e.target.value)} - helperText={`기본값: ${currentProviderInfo?.defaultModel || '자동'}`} - fullWidth - /> - - {/* Custom Base URL (for proxies or custom servers) */} - {activeProvider !== 'local' && ( - handleProviderConfigFieldChange('baseUrl', e.target.value)} - placeholder={currentProviderInfo?.defaultBaseUrl} - helperText="프록시 또는 사내 프라이빗 서버를 사용하는 경우 변경하세요." - fullWidth - /> - )} - - {/* Fallback to local Whisper toggle */} - updateConfig('sttFallbackToLocal', e.target.checked)} + /* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) */ + + {needsApiKey && handleProviderConfigFieldChange('apiKey', e.target.value)} + placeholder={ + activeProvider === 'openai' + ? 'sk-proj-...' + : activeProvider === 'groq' + ? 'gsk_...' + : t('settings.stt.apiKeyPlaceholder') + } size="small" - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { - backgroundColor: d3roPalette.tag.green, - }, + sx={controlSx} + inputProps={{ 'aria-labelledby': 'settings-stt-api-key', spellCheck: false, autoComplete: 'off' }} + InputProps={{ + endAdornment: ( + + setShowApiKey((v) => !v)} + edge="end" + aria-label={showApiKey ? t('settings.stt.hideKey') : t('settings.stt.showKey')} + > + {showApiKey ? : } + + + ), }} - /> - } - label={ - - - 로컬 Whisper 자동 폴백 (Auto Fallback) - - - 클라우드 STT 요청이 실패(인터넷 끊김, 쿼터 초과, 키 오류 등)할 경우 로컬 Whisper로 안전하게 자동 전환합니다. - - - } - /> - - - - - - - {/* ── 4. 언어 및 화자 구분 공통 설정 ── */} - - - 음성 인식 공통 설정 - - - - {t('settings.sttLanguage')} - - + {testResult && ( + + {testResult.message} + + )} +
+ )} + - - - - {t('settings.diarization')} - - - )['hfToken'] as string ?? ''} - onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)} - fullWidth - size="small" - helperText={t('settings.hfTokenHint')} + {/* ── 2. 언어 ── */} + + + + + } /> + - + )['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, - }, - }} + inputProps={{ 'aria-labelledby': 'settings-stt-diarization' }} /> } - label={ - - {t('settings.diarization')} - - {t('settings.diarizationHint')} - - + /> + )['hfToken'] as string ?? ''} + onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)} + size="small" + sx={controlSx} + inputProps={{ 'aria-labelledby': 'settings-stt-hf-token', spellCheck: false, autoComplete: 'off' }} + /> } /> - + + + {/* ── 4. 고급 (접힘) ── */} + : } + onClick={() => setAdvancedOpen((v) => !v)} + > + {advancedOpen ? t('settings.stt.advanced.hide') : t('settings.stt.advanced.show')} + + } + > + {advancedOpen && ( + + handleProviderConfigFieldChange('modelId', e.target.value)} + sx={controlSx} + inputProps={{ 'aria-labelledby': 'settings-stt-model-id', spellCheck: false }} + /> + } + /> + + {/* 프록시·사내 서버용 엔드포인트 */} + {activeProvider !== 'local' && ( + handleProviderConfigFieldChange('baseUrl', e.target.value)} + placeholder={currentProviderInfo?.defaultBaseUrl} + sx={controlSx} + inputProps={{ 'aria-labelledby': 'settings-stt-base-url', spellCheck: false }} + /> + } + /> + )} + + updateConfig('sttFallbackToLocal', e.target.checked)} + inputProps={{ 'aria-labelledby': 'settings-stt-fallback' }} + /> + } + /> + + )} + {/* Codex OAuth & API Key Setup Guide Modal */} setGuideOpen(false)} /> diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index 0658bdd..7c7977a 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -1,11 +1,10 @@ // src/renderer/components/SettingsModal.tsx -// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭 +// 설정 화면 — v4(2026-09-28)부터 모달이 아니라 메인 창의 전체 화면 라우트로 렌더된다(이름은 호환용으로 유지). +// open 은 "이 화면이 현재 보이는가" — 보일 때만 설정·장치·모델을 불러온다. onClose 는 이전 화면으로 돌아간다. +// 본문 문법: 구획(h3 + 1px 선) 안에 설정 행(왼쪽 라벨·설명, 오른쪽 컨트롤) — settings/SettingsRows. import { useState, useEffect, useCallback, useMemo } from 'react' import { - Dialog, - DialogTitle, - DialogContent, Tabs, Tab, Box, @@ -15,15 +14,11 @@ import { Switch, FormControlLabel, Typography, - IconButton, - Divider, - InputLabel, FormControl, Button, - Paper, } from '@mui/material' -import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, TextCursorInput } from 'lucide-react' -import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme' +import { Keyboard, Mic, Lock, Cloud, RefreshCw, Play, HelpCircle, TextCursorInput } from 'lucide-react' +import { d3roPalette, d3roRadius, d3roTransition, d3roTypo, typoSx } from '@d3ro/ui/theme' import { Led } from '@d3ro/ui/components/ds' import { LicenseTab } from './LicenseTab' import { CloudSyncSection } from './CloudSyncSection' @@ -32,6 +27,7 @@ import { CaptionModelSelect } from './CaptionModelSelect' import { InputConsentPanel } from './input-insights/InputConsentPanel' import { KeyBindingField } from './keybinding/KeyBindingField' import { asTranslationKey } from './keybinding/translation-key' +import { SettingNote, SettingRow, SettingsSection, SETTING_CONTROL_WIDTH } from './settings/SettingsRows' import { useI18n, LOCALE_META } from '@d3ro/i18n' import type { Locale } from '@d3ro/i18n' import type { KeyBindingActionGroup } from '@d3ro/core/keybinding' @@ -58,6 +54,28 @@ function TabPanel({ children, value, tab }: TabPanelProps): React.ReactElement | return {children} } +/** 행 오른쪽 Select·TextField 공통 폭 */ +const controlSx = { width: SETTING_CONTROL_WIDTH, maxWidth: '100%' } as const + +/** 행 오른쪽 스위치 — 라벨은 행 왼쪽 글자(aria-labelledby)가 맡는다. */ +function RowSwitch({ + labelId, + checked, + onChange, +}: { + labelId: string + checked: boolean + onChange: (checked: boolean) => void +}): React.ReactElement { + return ( + onChange(e.target.checked)} + inputProps={{ 'aria-labelledby': labelId }} + /> + ) +} + // ── 액션 그룹 섹션 ───────────────────────────────────── const ACTION_GROUP_LABEL_KEYS: Readonly> = { voice: 'keybinding.ui.sectionVoice', @@ -67,6 +85,12 @@ const ACTION_GROUP_LABEL_KEYS: Readonly> = const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input'] +/** 설치된 모델이 없을 때도 고를 수 있게 보여 주는 기본 후보 */ +const RECOMMENDED_OLLAMA_MODEL = 'gemma4:e4b' +const FALLBACK_OLLAMA_MODELS = ['llama3.2:3b', 'qwen2.5:3b'] as const + +type OllamaFeedback = { tone: 'info' | 'error'; message: string } + export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose }: SettingsModalProps): React.ReactElement { const { t, locale, setLocale } = useI18n() const [activeTab, setActiveTab] = useState(initialTab) @@ -78,7 +102,7 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose llm: { label: t('settings.tabs.llm') }, input: { label: t('input.tab'), icon: }, license: { label: t('license.nav'), icon: }, - cloud: { label: t('settings.tabs.cloud') ?? 'Cloud', icon: }, + cloud: { label: t('settings.tabs.cloud'), icon: }, about: { label: t('settings.tabs.about') }, }), [t], @@ -123,6 +147,7 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose const [selectedDeviceId, setSelectedDeviceId] = useState('default') const [micTesting, setMicTesting] = useState(false) const [micLevel, setMicLevel] = useState(0) + const [micTestFailed, setMicTestFailed] = useState(false) // 실시간 자막 모델 선택지 — 받아 둔 로컬 Whisper 모델만 const [downloadedSttModels, setDownloadedSttModels] = useState([]) @@ -133,12 +158,12 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose }) }, [open]) - // Ollama 상태 + // Ollama 상태 — null 은 "아직 조회하지 않음"(연결됨/오프라인으로 단정하지 않는다) const [ollamaStatus, setOllamaStatus] = useState(null) const [ollamaModels, setOllamaModels] = useState([]) const [checkingOllama, setCheckingOllama] = useState(false) const [startingOllama, setStartingOllama] = useState(false) - const [ollamaFeedback, setOllamaFeedback] = useState(null) + const [ollamaFeedback, setOllamaFeedback] = useState(null) const refreshOllama = useCallback(async () => { setCheckingOllama(true) @@ -195,150 +220,188 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose setConfig((prev) => ({ ...prev, language: newLocale })) }, [setLocale]) - if (loading) return + const handleMicTest = useCallback(async () => { + if (micTesting) { + // 메인 프로세스 캡처까지 실제로 중지 — done 이벤트가 구독 해제를 마무리 + await window.electronAPI.audio.stopTest() + setMicTesting(false) + setMicLevel(0) + return + } + setMicTesting(true) + setMicTestFailed(false) + setMicLevel(0) + // level 0은 무음/초기화 중일 뿐 — 종료 판정은 done 플래그로만 + const unsub = window.electronAPI.audio.onTestLevel((e) => { + setMicLevel(e.level) + if (e.done) { + setMicTesting(false) + setMicLevel(0) + unsub() + } + }) + const result = await window.electronAPI.audio.testDevice({ deviceId: selectedDeviceId }) + if (!result.success) { + setMicTesting(false) + setMicLevel(0) + setMicTestFailed(true) + unsub() + } + }, [micTesting, selectedDeviceId]) + const handleStartOllama = useCallback(async () => { + setStartingOllama(true) + setOllamaFeedback(null) + try { + const res = await window.electronAPI.llm.startServer() + if (!res.success) { + setOllamaFeedback({ tone: 'error', message: t('settings.llm.feedback.failed') }) + } else if (res.data === 'running') { + setOllamaFeedback({ tone: 'info', message: t('settings.llm.feedback.running') }) + } else if (res.data === 'starting') { + setOllamaFeedback({ tone: 'info', message: t('settings.llm.feedback.started') }) + setTimeout(() => refreshOllama(), 2000) + } else if (res.data === 'not-installed') { + setOllamaFeedback({ tone: 'error', message: t('settings.llm.feedback.notInstalled') }) + } + } finally { + setStartingOllama(false) + } + }, [refreshOllama, t]) + + if (loading) { + return ( + + {t('common.loading')} + + ) + } + + const ollamaConnected = ollamaStatus?.connectionState === 'connected' + const ollamaStatusText = + ollamaStatus === null + ? t('settings.llm.ollamaChecking') + : ollamaConnected + ? ollamaStatus.serverVersion + ? t('settings.llm.ollamaConnectedVersion', { version: ollamaStatus.serverVersion, count: ollamaModels.length }) + : t('settings.llm.ollamaConnected', { count: ollamaModels.length }) + : t('settings.llm.ollamaOffline') + const activeOllamaModel = config.llmModelId ?? RECOMMENDED_OLLAMA_MODEL + + // v4: 설정은 모달이 아니라 전체 화면 — 왼쪽 세로 탭 + 오른쪽 한 열(읽기 폭 제한). + // 모달 폭 600px 에 탭 8개가 넘쳐 '클라우드·정보'가 잘리던 문제(2026-09-28 실측)를 구조로 해결한다. return ( - <> - - + + + {t('settings.title')} - - - - - - - setActiveTab(v)} - variant="scrollable" - scrollButtons={false} - sx={{ - bgcolor: d3roPalette.bg.sidebar, - borderBottom: `1px solid ${d3roPalette.border.subtle}`, - minHeight: 40, - '& .MuiTab-root': { - fontFamily: d3roFontMono, - fontSize: d3roTypo.label.size, - fontWeight: 500, - letterSpacing: '0.5px', - textTransform: 'uppercase', - color: d3roPalette.text.inactive, - minHeight: 40, - py: 1, - '&.Mui-selected': { color: d3roPalette.accent.main }, - }, - '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }, - }} - > - {SETTINGS_TAB_ORDER.map((tab) => ( - - ))} - - + + setActiveTab(v)} + aria-label={t('settings.title')} + sx={{ + '& .MuiTabs-indicator': { display: 'none' }, + '& .MuiTab-root': { + alignItems: 'center', + justifyContent: 'flex-start', + gap: 1, + minHeight: 36, + px: 1.5, + py: 0, + borderRadius: d3roRadius.button, + color: d3roPalette.text.secondary, + fontSize: d3roTypo.compact.size, + fontWeight: 400, + textAlign: 'left', + '&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.surface.overlaySoft }, + '&.Mui-selected': { color: d3roPalette.text.primary, fontWeight: 500, bgcolor: d3roPalette.surface.overlayStrong }, + '& svg': { margin: 0 }, + }, + }} + > + {SETTINGS_TAB_ORDER.map((tab) => ( + + ))} + + + + + + {tabMeta[activeTab].label} + {/* ── 일반 탭 ─────────────────────────────── */} - - - - {t('settings.shortcuts')} - + handleKeybindingToggle(e.target.checked)} /> } label={ - + {t('keybinding.ui.globalEnabled')} } /> - - - - - {keybindingIssues.length === 0 - ? t('keybinding.ui.auditClear') - : t('keybinding.ui.auditIssues', { count: keybindingIssues.length })} - - {keybindingIssues.slice(0, 3).map((issue) => { - const action = KEYBINDING_ACTIONS.find((entry) => entry.id === issue.actionId) - const actionLabel = action ? t(asTranslationKey(action.labelKey)) : issue.actionId - const conflictLabels = issue.conflictActionIds - .map((actionId) => { - const conflict = KEYBINDING_ACTIONS.find((entry) => entry.id === actionId) - return conflict ? t(asTranslationKey(conflict.labelKey)) : actionId - }) - .join(', ') - return ( - - {issue.kind === 'invalid' - ? t('keybinding.ui.auditInvalid', { - action: actionLabel, - reason: issue.reasonKey ? t(asTranslationKey(issue.reasonKey)) : t('keybinding.ui.auditUnknown') - }) - : t('keybinding.ui.auditConflict', { action: actionLabel, conflicts: conflictLabels })} - - ) - })} - - + } + description={ + + + {keybindingIssues.length === 0 + ? t('keybinding.ui.auditClear') + : t('keybinding.ui.auditIssues', { count: keybindingIssues.length })} + + {keybindingIssues.slice(0, 3).map((issue) => { + const action = KEYBINDING_ACTIONS.find((entry) => entry.id === issue.actionId) + const actionLabel = action ? t(asTranslationKey(action.labelKey)) : issue.actionId + const conflictLabels = issue.conflictActionIds + .map((actionId) => { + const conflict = KEYBINDING_ACTIONS.find((entry) => entry.id === actionId) + return conflict ? t(asTranslationKey(conflict.labelKey)) : actionId + }) + .join(', ') + return ( + + {issue.kind === 'invalid' + ? t('keybinding.ui.auditInvalid', { + action: actionLabel, + reason: issue.reasonKey ? t(asTranslationKey(issue.reasonKey)) : t('keybinding.ui.auditUnknown') + }) + : t('keybinding.ui.auditConflict', { action: actionLabel, conflicts: conflictLabels })} + + ) + })} + + } + > {ACTION_GROUP_ORDER.map((group) => { const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group) if (actions.length === 0) return null return ( - + {t(asTranslationKey(ACTION_GROUP_LABEL_KEYS[group]))} @@ -348,204 +411,222 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose ) })} + - + + + + + } + /> + + + + } + /> + - - {t('settings.interface')} - - - - {t('settings.theme')} - - - - - {t('settings.language')} - - - - - - - {t('settings.appBehavior')} - - - updateConfig('closeToTray', e.target.checked)} />} + + updateConfig('closeToTray', checked)} + /> + } /> - updateConfig('autoLaunch', e.target.checked)} />} + updateConfig('autoLaunch', checked)} + /> + } /> - updateConfig('autoInsert', e.target.checked)} />} + updateConfig('autoInsert', checked)} + /> + } /> - updateConfig('soundEnabled', e.target.checked)} />} + updateConfig('soundEnabled', checked)} + /> + } /> - + {/* ── 오디오 탭 ────────────────────────────── */} - - - {t('settings.microphone')} - - - - {t('settings.inputDevice')} - { + const deviceId = e.target.value + setSelectedDeviceId(deviceId) + window.electronAPI.audio.setSelectedDevice({ deviceId }) + }} + > + {audioDevices.map((device, idx) => ( + + {device.label}{device.isDefault ? ` ${t('settings.deviceDefault')}` : ''} + + ))} + + + } + /> + + {micTesting ? t('common.stop') : t('common.test')} + + } + > + - {audioDevices.map((device, idx) => ( - - {device.label}{device.isDefault ? ` ${t('settings.deviceDefault')}` : ''} - - ))} - - - - - - - 0.7 ? d3roPalette.tag.red : d3roPalette.accent.main, borderRadius: '4px', transition: 'width 100ms ease-out' }} /> + 0.7 ? d3roPalette.status.warning : d3roPalette.accent.main, + transition: d3roTransition('width'), + }} + /> - - - - - - {t('settings.captionAudio')} - - - - {t('settings.captionSource')} - - + {micTestFailed && ( + + {t('settings.micTest.failed')} + + )} + + + + + + + } + /> updateConfig('captionSttModelId', modelId)} /> - - updateConfig('captionRefineEnabled', e.target.checked)} + onChange={(checked) => updateConfig('captionRefineEnabled', checked)} /> } - label={ - - - {t('settings.captionRefine')} - - - {t('settings.captionRefine.desc')} - - + /> + + + + + + } /> - - - - - {t('settings.textInsert')} - - - - {t('settings.insertMethod')} - - - + {/* ── STT 탭 ───────────────────────────────── */} @@ -555,267 +636,231 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose {/* ── LLM 탭 ──────────────────────────────── */} - - {/* LLM 백엔드 선택 */} - - - AI 처리 백엔드 선택 - - - - - + + + + + } + /> - {/* 로컬 Ollama 설정 */} + {/* 로컬 Ollama */} {config.llmBackend !== 'online' && ( - - {/* Ollama 연결 상태 카드 */} - - - - - - {ollamaStatus?.connectionState === 'connected' - ? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}` - : 'Ollama 오프라인 (서버 실행 필요)'} - - - 설치된 모델: {ollamaModels.length}개 | 현재 활성: {config.llmModelId ?? 'gemma4:e4b'} - + <> + + {ollamaStatus !== null && } + {ollamaStatusText} - - - - - {ollamaStatus?.connectionState !== 'connected' && ( + } + control={ + <> - )} + {ollamaStatus !== null && !ollamaConnected && ( + + )} + + } + > + {ollamaFeedback && ( + + {ollamaFeedback.message} + + )} + + + } onClick={() => window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide'))} - sx={{ fontSize: '11px', color: d3roPalette.accent.main }} > - 가이드/모델 다운로드 + {t('settings.llm.openGuide')} - - + } + /> - {ollamaFeedback && ( - - {ollamaFeedback} - - )} + + + + } + /> - {/* 서버 URL & 모델 선택 */} - - updateConfig('ollamaServerUrl', e.target.value)} - fullWidth - size="small" - /> - - - 활성 Ollama 모델 - - - - + updateConfig('ollamaServerUrl', e.target.value)} + size="small" + sx={controlSx} + inputProps={{ 'aria-labelledby': 'settings-ollama-url', spellCheck: false }} + /> + } + /> + )} - {/* 온라인 API 설정 */} + {/* 온라인 API */} {config.llmBackend === 'online' && ( - - updateConfig('onlineApiUrl', e.target.value)} - fullWidth - size="small" + <> + updateConfig('onlineApiUrl', e.target.value)} + size="small" + sx={controlSx} + inputProps={{ 'aria-labelledby': 'settings-online-url', spellCheck: false }} + /> + } /> - - 계정 상태: {config.authToken ? `${config.userEmail ?? '로그인됨'} (온라인 API 가용)` : '로그인 필요'} - - + + )} + - - - - {t('settings.postProcess')} - - - - {t('settings.defaultAction')} - - - - - {t('settings.actionHint')} - - - - - {/* Phase 10: 음성 명령어 토글 */} - - {t('settings.voiceCommands')} - - + + + + } + /> + )['voiceCommandsEnabled'] as boolean ?? false} - onChange={async (_, checked) => { + onChange={(checked) => { updateConfig('voiceCommandsEnabled' as keyof AppConfig, checked as never) - await window.electronAPI.voiceCommand.setEnabled({ enabled: checked }) + void window.electronAPI.voiceCommand.setEnabled({ enabled: checked }) }} - size="small" /> } - label={ - - {t('settings.voiceCommands')} - {t('settings.voiceCommands.desc')} - - } /> - - {/* Phase 10: 화면 컨텍스트 토글 */} - - {t('settings.screenContext')} - - { + onChange={(checked) => { updateConfig('screenContextEnabled', checked) - await window.electronAPI.context.setEnabled({ enabled: checked }) + void window.electronAPI.context.setEnabled({ enabled: checked }) }} - size="small" /> } - label={ - - {t('settings.screenContext')} - {t('settings.screenContext.desc')} - - } /> - + - {/* ── 라이선스 탭 ────────────────────────────── */} + {/* ── 입력 인사이트 탭 ─────────────────────────── */} + {/* ── 라이선스 탭 ────────────────────────────── */} @@ -827,54 +872,43 @@ export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose {/* ── 정보 탭 ──────────────────────────────── */} - - - D3RO-VOICE - + + + {appVersion === null ? '—' : `v${appVersion}`} + + } + /> + + + - - {t('settings.about.version')} - - {appVersion === null ? '—' : `v${appVersion}`} - - - - - {t('settings.about.techStack')} - {t('settings.about.techStackValue')} - - - - {t('settings.about.voiceEngine')} - {t('settings.about.voiceEngineValue')} - - - - - - {t('settings.about.description')} - - - - + + { + window.electronAPI.config.set({ + key: 'onboardingCompleted', + value: false, + }) + onClose() + setTimeout(() => window.location.reload(), 300) + }} + > + {t('settings.about.restartOnboarding.action')} + + } + /> + - - - - + + ) } diff --git a/apps/desktop/src/renderer/components/StatusBar.tsx b/apps/desktop/src/renderer/components/StatusBar.tsx deleted file mode 100644 index d78530c..0000000 --- a/apps/desktop/src/renderer/components/StatusBar.tsx +++ /dev/null @@ -1,204 +0,0 @@ -// src/renderer/components/StatusBar.tsx -// v3 "Tactile Liquid & Midnight Glass" Bottom Telemetry Bar - -import { useState, useEffect } from 'react' -import { Box, Typography, Fade, IconButton } from '@mui/material' -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' -import type { LLMStatus } from '@d3ro/core/types' - -function useClock(): string { - const [now, setNow] = useState(() => new Date()) - useEffect(() => { - const timer = setInterval(() => setNow(new Date()), 1000) - return () => clearInterval(timer) - }, []) - const pad = (n: number): string => String(n).padStart(2, '0') - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` -} - -export function StatusBar(): React.ReactElement { - const { t } = useI18n() - const [llmStatus, setLlmStatus] = useState(null) - const [showNudge, setShowNudge] = useState(false) - const [guideOpen, setGuideOpen] = useState(false) - const [captionActive, setCaptionActive] = useState(false) - const [version, setVersion] = useState('') - const clock = useClock() - - // 자막 상태 구독 - useEffect(() => { - window.electronAPI.caption.getState().then((r) => { - if (r.success) setCaptionActive(r.data === 'active') - }) - const unsub = window.electronAPI.caption.onStateChanged((data) => { - setCaptionActive(data.state === 'active') - }) - return unsub - }, []) - - useEffect(() => { - window.electronAPI.system.getVersion().then((r) => { - if (r.success) setVersion(r.data) - }) - window.electronAPI.llm.getStatus().then((r) => { - if (r.success) setLlmStatus(r.data) - }) - const unsub = window.electronAPI.llm.onStatusChanged((e) => setLlmStatus(e.status)) - - 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' - - useEffect(() => { - if (!connected) { - const showTimer = setTimeout(() => setShowNudge(true), 3000) - const hideTimer = setTimeout(() => setShowNudge(false), 13000) - return () => { clearTimeout(showTimer); clearTimeout(hideTimer) } - } - setShowNudge(false) - return undefined - }, [connected]) - - return ( - <> - - {/* 넛징 버블 */} - - - - - {t('status.nudge.title')} - - setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 1 }}> - - - - - {t('status.nudge.desc')} - - { setGuideOpen(true); setShowNudge(false) }} - sx={{ - fontSize: d3roTypo.meta.size, - color: d3roPalette.accent.light, - fontWeight: d3roTypo.label.weight, - cursor: 'pointer', - '&:hover': { textDecoration: 'underline' }, - }} - > - {t('status.nudge.guide')} - - - - - {/* 좌: 브랜드 + 버전 + LLM 상태 */} - !connected && setGuideOpen(true)} - > - - - D3RO Voice - - {version && ( - - v{version} - - )} - - - {llmStatus?.activeModel && ( - - {llmStatus.activeModel} - - )} - - {/* 자막 상태 표시 */} - {captionActive && ( - - LIVE CAPTION - - )} - - - - {/* 중/우: 100% 온디바이스 프라이버시 인디케이터 */} - - - - 100% LOCAL PRIVACY · ZERO CLOUD SYNC - - - - - - {/* 우: 로컬 시간 */} - - - {clock} - - - - - setGuideOpen(false)} /> - - ) -} diff --git a/apps/desktop/src/renderer/components/TemplateSection.tsx b/apps/desktop/src/renderer/components/TemplateSection.tsx index 332c944..89ceb2b 100644 --- a/apps/desktop/src/renderer/components/TemplateSection.tsx +++ b/apps/desktop/src/renderer/components/TemplateSection.tsx @@ -1,17 +1,32 @@ // src/renderer/components/TemplateSection.tsx -// Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 내 섹션) +// Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 의 접히는 구획 안). +// 2026-09-28: 구획 제목(h2)은 CommandsPage 가 그린다 — 여기서 PageHeader(h1)를 또 그리지 않는다. +// 한 건 삭제는 "삭제했습니다 · 되돌리기", 아이콘 버튼은 테마 기본 + aria-label. -import { useState, useEffect, useCallback, useRef } from 'react' -import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip } from '@mui/material' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip, Typography } from '@mui/material' import { Plus, Trash2, Pencil, Play } from 'lucide-react' -import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds' -import { PageHeader, EmptyStateCard } from './shared' -import { d3roPalette, d3roTypo } from '@d3ro/ui/theme' +import { Led, PhysicalButton } from '@d3ro/ui/components/ds' +import { EmptyStateCard, useNotify } from './shared' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types' +import { nextTemplateFieldNumber, templateFieldIdProblems, TEMPLATE_FIELD_ID_PREFIX } from '@d3ro/core/template-field-policy' +import { ToolRow, TOOL_TEXT_SX } from '../pages/tools/ToolSection' +import { useDeferredDelete } from '../pages/tools/useDeferredDelete' +import { withoutHidden } from '../pages/tools/tools-model' + +/** 새 필드 — 번호는 기존 필드 id와 겹치지 않게 고른다(필드 개수로 만들면 삭제 후 id가 다시 나온다). */ +function newTemplateField(existing: readonly TemplateField[]): TemplateField { + const n = nextTemplateFieldNumber(existing) + const id = `${TEMPLATE_FIELD_ID_PREFIX}${n}` + return { id, name: id, label: `Field ${n}`, promptText: '', required: true, maxDurationSec: 30 } +} export function TemplateSection(): React.ReactElement { const { t } = useI18n() + const notify = useNotify() + const { hidden, remove } = useDeferredDelete() const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) @@ -26,7 +41,6 @@ export function TemplateSection(): React.ReactElement { const [saving, setSaving] = useState(false) const isSavingRef = useRef(false) - const isDeletingRef = useRef(false) const loadData = useCallback(async () => { setLoading(true) @@ -66,7 +80,7 @@ export function TemplateSection(): React.ReactElement { setFormName('') setFormDesc('') setFormOutput('{{field1}}') - setFormFields([{ id: 'field1', name: 'field1', label: 'Field 1', promptText: '', required: true, maxDurationSec: 30 }]) + setFormFields([newTemplateField([])]) setDialogOpen(true) } @@ -80,8 +94,13 @@ export function TemplateSection(): React.ReactElement { setDialogOpen(true) } + // 필드 이름이 곧 id({{이름}} 자리표시자 키)다 — 비었거나 겹치면 받아쓴 값이 서로 덮어쓰므로 저장을 막는다. + const fieldIdProblems = useMemo(() => templateFieldIdProblems(formFields), [formFields]) + const hasFieldIdProblem = fieldIdProblems.some((problem) => problem !== null) + const canSave = formName.trim().length > 0 && formFields.length > 0 && !hasFieldIdProblem && !saving + const handleSave = async () => { - if (!formName.trim() || formFields.length === 0 || saving || isSavingRef.current) return + if (!canSave || isSavingRef.current) return isSavingRef.current = true setSaving(true) try { @@ -109,15 +128,13 @@ export function TemplateSection(): React.ReactElement { } } - const handleDelete = async (id: string) => { - if (isDeletingRef.current) return - isDeletingRef.current = true - try { - await window.electronAPI.dictationTemplate.delete({ id }) - loadData() - } finally { - isDeletingRef.current = false - } + const handleDelete = (template: DictationTemplate) => { + remove({ + id: template.id, + message: t('tools.templates.deleted', { name: template.name }), + commit: async () => (await window.electronAPI.dictationTemplate.delete({ id: template.id })).success, + onCommitted: () => void loadData(), + }) } const handleStartSession = async (templateId: string) => { @@ -126,7 +143,11 @@ export function TemplateSection(): React.ReactElement { } const handleCopyOutput = () => { - if (lastOutput) void navigator.clipboard.writeText(lastOutput) + if (!lastOutput) return + navigator.clipboard.writeText(lastOutput).then( + () => notify({ message: t('common.copied') }), + () => notify({ message: t('tools.copyFailed'), severity: 'error' }), + ) } const handleCancelSession = async () => { @@ -135,15 +156,7 @@ export function TemplateSection(): React.ReactElement { } const addField = () => { - const idx = formFields.length + 1 - setFormFields([...formFields, { - id: `field${idx}`, - name: `field${idx}`, - label: `Field ${idx}`, - promptText: '', - required: true, - maxDurationSec: 30, - }]) + setFormFields([...formFields, newTemplateField(formFields)]) } const updateField = (index: number, updates: Partial) => { @@ -156,29 +169,32 @@ export function TemplateSection(): React.ReactElement { setFormFields(formFields.filter((_, i) => i !== index)) } - return ( - - - {t('template.create')} - - } - /> + const visibleTemplates = withoutHidden(templates, hidden) - {/* 활성 세션 표시 */} + return ( + + + {t('tools.templates.description')} + } sx={{ flexShrink: 0 }}> + {t('template.create')} + + + + {/* 진행 중인 템플릿 받아쓰기 — 녹음 상태이므로 강조색 선 */} {session && ( - - - - - - {session.templateName}: {session.currentField?.label ?? '...'} - - - ({session.currentFieldIndex + 1}/{session.totalFields}) - + + + + + + {session.templateName}: {session.currentField?.label ?? '…'} + + + {session.currentFieldIndex + 1}/{session.totalFields} + {t('template.cancelSession')} @@ -186,81 +202,77 @@ export function TemplateSection(): React.ReactElement { {/* 현재 필드의 음성 프롬프트 — 받아쓰기 결과가 이 필드 값으로 들어간다 */} {session.currentField?.promptText && ( - - {session.currentField.promptText} - + {session.currentField.promptText} )} - + )} {/* 마지막 완성 결과 */} {!session && lastOutput && ( - - + + {t('tools.templates.lastOutput')} + {lastOutput} - + {t('common.copy')} - setLastOutput(null)}> + setLastOutput(null)}> {t('common.close')} - + )} {/* 템플릿 목록 */} - {templates.length === 0 && !loading ? ( - + {visibleTemplates.length === 0 && !loading ? ( + ) : ( - - {templates.map((tmpl) => ( - - - - - {tmpl.name} - {tmpl.isBuiltin && ( - - PRESET - - )} - - - {tmpl.fields.length} {t('template.fields').toLowerCase()} — {tmpl.description} - + + {visibleTemplates.map((tmpl) => ( + + + + {tmpl.name} + {tmpl.isBuiltin && {t('tools.builtin')}} - - + + {t('tools.templates.fieldCount', { count: tmpl.fields.length })} + {tmpl.description ? ` · ${tmpl.description}` : ''} + + + + + handleStartSession(tmpl.id)} + aria-label={t('tools.templates.startItem', { name: tmpl.name })} + onClick={() => void handleStartSession(tmpl.id)} disabled={!!session} - sx={{ color: d3roPalette.accent.main, '&:hover': { opacity: 0.8 } }} > - + + + + + + openEdit(tmpl)}> + + + + {!tmpl.isBuiltin && ( + + handleDelete(tmpl)} + > + - openEdit(tmpl)} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }} - > - - - {!tmpl.isBuiltin && ( - handleDelete(tmpl.id)} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }} - > - - - )} - + )} - + ))} )} @@ -284,9 +296,9 @@ export function TemplateSection(): React.ReactElement { fullWidth /> - - {t('template.fields').toUpperCase()} - + + {t('template.fields')} + {formFields.map((field, idx) => ( @@ -294,6 +306,7 @@ export function TemplateSection(): React.ReactElement { label={t('template.fieldName')} value={field.name} onChange={(e) => updateField(idx, { name: e.target.value, id: e.target.value })} + error={fieldIdProblems[idx] !== null} size="small" sx={{ flex: 1 }} /> @@ -313,9 +326,9 @@ export function TemplateSection(): React.ReactElement { /> removeField(idx)} disabled={formFields.length <= 1} - sx={{ color: d3roPalette.text.inactive }} > @@ -338,10 +351,10 @@ export function TemplateSection(): React.ReactElement { /> - - diff --git a/apps/desktop/src/renderer/components/TitleBar.tsx b/apps/desktop/src/renderer/components/TitleBar.tsx index fe886ff..d599e99 100644 --- a/apps/desktop/src/renderer/components/TitleBar.tsx +++ b/apps/desktop/src/renderer/components/TitleBar.tsx @@ -16,14 +16,14 @@ export function TitleBar(): React.ReactElement { const [isMaximized, setIsMaximized] = useState(false) useEffect(() => { - window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max)) + window.electronAPI.window.isMaximized().then((r) => { if (r.success) setIsMaximized(r.data) }) }, []) const handleMaximize = useCallback(() => { window.electronAPI.window.maximize() // 토글 후 상태 재조회 (이벤트 채널이 없어 폴링 1회) setTimeout(() => { - window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max)) + window.electronAPI.window.isMaximized().then((r) => { if (r.success) setIsMaximized(r.data) }) }, 120) }, []) diff --git a/apps/desktop/src/renderer/components/UpgradePromptModal.tsx b/apps/desktop/src/renderer/components/UpgradePromptModal.tsx index d994b1b..7b3bcbb 100644 --- a/apps/desktop/src/renderer/components/UpgradePromptModal.tsx +++ b/apps/desktop/src/renderer/components/UpgradePromptModal.tsx @@ -1,5 +1,6 @@ // src/renderer/components/UpgradePromptModal.tsx // Phase 11: 업그레이드 유도 모달 — 쿼터 소진 또는 잠긴 기능 접근 시 표시 +// v4(2026-09-28): Dialog 기본 표면 + 제목 h2 + 본문 + 동사 버튼. 모노 글꼴·대문자 라벨 제거. import { useState, useEffect, useCallback } from 'react' import { @@ -12,10 +13,11 @@ import { Box, LinearProgress, } from '@mui/material' -import { Lock, CheckCircle2 } from 'lucide-react' -import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { Lock, Check } from 'lucide-react' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { openSettings } from '../navigation/settingsTabs' +import { featureLabelKey, tierLabelKey } from '../utils/display-labels' import type { UpgradePromptEvent, UsageQuota } from '@d3ro/core/types' export function UpgradePromptModal(): React.ReactElement { @@ -45,8 +47,8 @@ export function UpgradePromptModal(): React.ReactElement { const isQuota = event.reason === 'quota_exceeded' const isLoginRequired = event.reason === 'login_required' - const featureLabel = t(`license.feature.${event.feature}`) - const tierLabel = event.requiredTier === 'pro_plus' ? t('license.proPlus') : t('license.pro') + const featureLabel = t(featureLabelKey(event.feature)) + const tierLabel = t(tierLabelKey(event.requiredTier === 'pro_plus' ? 'pro_plus' : 'pro')) const openCloudSyncSettings = (): void => { setOpen(false) @@ -54,33 +56,14 @@ export function UpgradePromptModal(): React.ReactElement { } return ( - + - + + + {isQuota ? t('license.quotaExceeded.title', { feature: featureLabel }) : isLoginRequired @@ -89,14 +72,7 @@ export function UpgradePromptModal(): React.ReactElement { - + {isQuota ? t('license.quotaExceeded.desc') : isLoginRequired @@ -109,75 +85,40 @@ export function UpgradePromptModal(): React.ReactElement { )} - {/* 혜택 목록 */} - - - {t('license.upgradeBenefits')} - - {[ - t('license.benefit.unlimitedDictation'), - t('license.benefit.unlimitedLLM'), - t('license.benefit.liveCaption'), - t('license.benefit.unlimitedHistory'), - ].map((benefit) => ( - + - - - {benefit} - + {t('license.upgradeBenefits')} + + + {[ + t('license.benefit.unlimitedDictation'), + t('license.benefit.unlimitedLLM'), + t('license.benefit.liveCaption'), + t('license.benefit.unlimitedHistory'), + ].map((benefit) => ( + + + + {benefit} + + + ))} - ))} - + + )} - - - @@ -190,42 +131,28 @@ export function UpgradePromptModal(): React.ReactElement { function QuotaBar({ quota }: { quota: UsageQuota }): React.ReactElement { const { t } = useI18n() const progress = quota.limit > 0 ? (quota.used / quota.limit) * 100 : 100 - const featureLabel = t(`license.feature.${quota.feature}`) + const featureLabel = t(featureLabelKey(quota.feature)) return ( - + {featureLabel} - - {t('license.quotaUsed', { - used: String(quota.used), - limit: String(quota.limit), - })} + + {t('license.quotaUsed', { used: quota.used, limit: quota.limit })} = 100 ? d3roPalette.tag.red : d3roPalette.accent.main, + bgcolor: progress >= 100 ? d3roPalette.status.danger : d3roPalette.text.secondary, borderRadius: d3roRadius.xs, }, }} diff --git a/apps/desktop/src/renderer/components/ads/AdBanner.tsx b/apps/desktop/src/renderer/components/ads/AdBanner.tsx index b227cd7..ecbf71b 100644 --- a/apps/desktop/src/renderer/components/ads/AdBanner.tsx +++ b/apps/desktop/src/renderer/components/ads/AdBanner.tsx @@ -1,10 +1,13 @@ // apps/desktop/src/renderer/components/ads/AdBanner.tsx -// Dynamic Multi-Network Dock Sponsor Banner connected to 10+ Ad Mediation Engine +// 무료 등급 본문 아래 광고 띠. 광고 중개(requestAuction)가 돌려준 광고만 그린다. +// v4(2026-09-28): 광고임을 먼저 밝히는 "광고" 표지, 중립 표면 + 1px 선. 네트워크별 색 배지·강조색 CTA·글로우 제거 — +// 광고가 앱의 주 행동(강조색)과 경쟁하지 않는다. 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 { Box, Typography, Button, Link } from '@mui/material' +import { ExternalLink } from 'lucide-react' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' import type { LicenseTier, AdCreativePayload } from '@d3ro/core/types' interface AdBannerProps { @@ -14,6 +17,7 @@ interface AdBannerProps { } export function AdBanner({ tier, onUpgrade }: AdBannerProps): React.ReactElement | null { + const { t } = useI18n() const [creative, setCreative] = useState(null) // Fetch highest bidding ad creative via mediation auction @@ -51,7 +55,7 @@ export function AdBanner({ tier, onUpgrade }: AdBannerProps): React.ReactElement return () => clearInterval(interval) }, [fetchAdAuction]) - // Pro, Pro+, Team, Enterprise users are 100% Ad-Free + // 유료 등급(Pro·Pro+·팀·엔터프라이즈)에는 광고를 보이지 않는다 if (tier !== 'free' || creative === null) { return null } @@ -65,156 +69,49 @@ export function AdBanner({ tier, onUpgrade }: AdBannerProps): React.ReactElement return ( - {/* Network-Specific Authentic Test Ad Badge / Watermark */} + {/* 광고 표지 — 무엇보다 먼저, 작게 */} - {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' - ) : ( - - )} + {creative.networkId === 'google_ad_manager' ? t('ads.testAd') : t('ads.label')} - - {/* Google / Network Official Test Ad Ribbon */} - {creative.networkId === 'google_ad_manager' && ( - - TEST AD - - )} - - + + {creative.title} - - - {creative.sponsorTag || 'Sponsor'} - + {(creative.sponsorTag || creative.advertiserName) && ( + + {creative.sponsorTag || creative.advertiserName} + + )} - - + {creative.description} @@ -223,41 +120,23 @@ export function AdBanner({ tier, onUpgrade }: AdBannerProps): React.ReactElement - - Remove ads with Pro - + {t('ads.removeWithPro')} + ) diff --git a/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx b/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx index 9f75bfe..b49aed3 100644 --- a/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx +++ b/apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx @@ -1,11 +1,13 @@ // apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx // Rewarded ad UI. It renders only a provider-returned creative and grants // nothing until the main-process verification boundary confirms it. +// v4(2026-09-28): MUI Dialog 기본 표면 + 제목 h2 + "광고" 표지. 글로우·청록 배지·가짜 동영상 크롬 제거, 문구 i18n(ads.rewarded.*). 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 { Dialog, DialogTitle, DialogContent, DialogActions, Box, Typography, Button, IconButton, LinearProgress } from '@mui/material' +import { X } from 'lucide-react' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' import type { AdCreativePayload } from '@d3ro/core/types' interface RewardedQuotaModalProps { @@ -19,6 +21,7 @@ export function RewardedQuotaModal({ onClose, onRewardClaimed, }: RewardedQuotaModalProps): React.ReactElement { + const { t } = useI18n() const [creative, setCreative] = useState(null) const [timeLeft, setTimeLeft] = useState(0) const [completed, setCompleted] = useState(false) @@ -50,13 +53,13 @@ export function RewardedQuotaModal({ return } } - setErrorMessage('A verified rewarded ad is not available right now.') + setErrorMessage(t('ads.rewarded.notAvailable')) } catch { - setErrorMessage('The rewarded ad provider could not be reached.') + setErrorMessage(t('ads.rewarded.unreachable')) } finally { setLoading(false) } - }, []) + }, [t]) useEffect(() => { if (!open) { @@ -99,36 +102,40 @@ export function RewardedQuotaModal({ return } } - setErrorMessage('Reward verification failed. No tokens were added.') + setErrorMessage(t('ads.rewarded.verifyFailed')) } + const titleId = 'rewarded-quota-title' + const title = ( + + {creative === null && !loading ? t('ads.rewarded.unavailableTitle') : t('ads.rewarded.title')} + + + + + ) + if (creative === null) { return ( - - - - Rewarded ad unavailable + + {title} + + + {loading ? t('ads.rewarded.checking') : errorMessage ?? t('ads.rewarded.notAvailable')} - - {loading ? 'Checking verified ad providers…' : errorMessage ?? 'No verified ad is available.'} - - - - + + + + + ) } @@ -136,211 +143,100 @@ export function RewardedQuotaModal({ const progressPercent = ((duration - timeLeft) / duration) * 100 return ( - - - {/* Header */} - - - - - - - Verified rewarded ad - - - - - - - - {/* Video Ad Screen with Authentic Unity / AppLovin Test Mode UI */} + + {title} + + {/* 광고 표면 — 중립 1px 상자 + "광고" 표지 */} - {/* Unity / AppLovin Official Test Watermark Ribbon */} - - + + {t('ads.label')} + + {creative.networkName} - - Reward verification required - - - - {completed ? 'Reward Ready' : `00:${timeLeft.toString().padStart(2, '0')}`} - + {completed ? t('ads.rewarded.ready') : t('ads.rewarded.remaining', { seconds: timeLeft })} + - {/* Test Commercial Creative Content */} - - - - - + {creative.title} - + {creative.description} - {/* Video Progress Bar at bottom */} - - - + - {/* Footer Actions */} - - - Reward is added only after server verification. + + {t('ads.rewarded.verifyNote')} + + {errorMessage && ( + + {errorMessage} + )} + - {completed ? ( - - ) : ( - - )} - - - + + + + + ) } diff --git a/apps/desktop/src/renderer/components/input-insights/BarChart.tsx b/apps/desktop/src/renderer/components/input-insights/BarChart.tsx index 1b4804f..abbbc94 100644 --- a/apps/desktop/src/renderer/components/input-insights/BarChart.tsx +++ b/apps/desktop/src/renderer/components/input-insights/BarChart.tsx @@ -43,7 +43,7 @@ export function BarChart({ if (data.length === 0 || !hasData) { return ( - + {t('input.chart.noData')} ) @@ -52,7 +52,7 @@ export function BarChart({ return ( - + {t('input.chart.max', { value: formatValue(max) })} @@ -95,7 +95,7 @@ export function BarChart({ {label} - + {right ?? `${Math.round(percent * 100)}%`} {/* ── 동의 ─────────────────────────────────────── */} - + {t('input.consent.title')} {t('input.consent.description')} @@ -191,7 +191,7 @@ export function InputConsentPanel(): React.ReactElement { gap: 0.5 }} > - + {t('input.privacy.title')} {t('input.privacy.localOnly')} @@ -246,7 +246,7 @@ export function InputConsentPanel(): React.ReactElement { gap: 0.25 }} > - + {t('input.diagnostics.title')} @@ -273,12 +273,12 @@ export function InputConsentPanel(): React.ReactElement { : t('input.diagnostics.notReadable')} {snapshot?.composing ? ( - + {t('input.diagnostics.composing')} ) : null} {snapshot?.caretFallback && snapshot.editable ? ( - + {t('input.diagnostics.caretFallback')} ) : null} @@ -381,7 +381,7 @@ export function InputConsentPanel(): React.ReactElement { ) : null} - + {t('input.insights.statsMovedHint')} @@ -390,7 +390,7 @@ export function InputConsentPanel(): React.ReactElement { {/* ── 제안 정책 ─────────────────────────────────── */} - + {t('input.suggestion.title')} {t('input.suggestion.description')} @@ -400,6 +400,8 @@ export function InputConsentPanel(): React.ReactElement { void handleSuggestionToggle(e.target.checked)} /> } @@ -491,7 +493,7 @@ export function InputConsentPanel(): React.ReactElement { {/* 상태 표시 — 스위치로 두면 설정처럼 보여서 "왜 못 켜지?" 가 된다. */} - + {t('input.suggestion.onScreenLabel')} {suggestion?.visible ? t('input.suggestion.onScreen') : t('input.suggestion.offScreen')} @@ -545,7 +547,7 @@ export function InputConsentPanel(): React.ReactElement { {suggestion?.lastSkipReason ? ( - + {t('input.suggestion.lastSkip', { reason: suggestion.lastSkipReason })} ) : null} diff --git a/apps/desktop/src/renderer/components/input-insights/InputInsightsView.tsx b/apps/desktop/src/renderer/components/input-insights/InputInsightsView.tsx index 32f03ee..a5d1848 100644 --- a/apps/desktop/src/renderer/components/input-insights/InputInsightsView.tsx +++ b/apps/desktop/src/renderer/components/input-insights/InputInsightsView.tsx @@ -1,8 +1,10 @@ // src/renderer/components/input-insights/InputInsightsView.tsx // 입력 인사이트 — 지식 베이스 안에 들어가는 상세 통계 화면. // -// 종류별 inner tab 으로 나눈다: 요약 / 키보드 / 마우스 / 앱 / 문구·제안. +// 종류별 inner tab 으로 나눈다: 요약 / 키보드 / 마우스 / 앱 / 문구와 제안 / 문장 그래프. // 모든 수치는 수집된 로컬 집계(input_activity)와 제안 이력(suggestions)에서 나온다. +// v4(2026-09-28): 탭 모양은 지식 베이스 탭과 하나(TOOL_TABS_SX), 강조색은 선택된 기간·탭 표시에만. +// 상태를 읽기 전에는 '꺼져 있음'이라 쓰지 않는다. 일별 마우스 이동 단위 오류(px→m 변환이 1550배 컸다)를 고쳤다. import { useCallback, useEffect, useState } from 'react' import { @@ -16,16 +18,25 @@ import { Typography } from '@mui/material' import { Trash2 } from 'lucide-react' -import { d3roPalette, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, d3roTypo, d3roFocusRing, d3roTransition, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import { pixelsToMeters } from '@d3ro/core/input-intelligence' import { BarChart, ShareBar, type BarDatum } from './BarChart' import { useInputInsights } from '../../hooks/useInputInsights' +import { openSettings } from '../../navigation/settingsTabs' +import { TOOL_TABS_SX } from '../../pages/tools/ToolSection' +import { useDeferredDelete } from '../../pages/tools/useDeferredDelete' +import { withoutHidden } from '../../pages/tools/tools-model' import type { PersonalGraphQuery, PersonalGraphStats } from '@d3ro/core/personal-graph' -const OVERLINE_SX = { color: d3roPalette.text.label, letterSpacing: '1.5px' } as const +type InsightsTab = 'overview' | 'keyboard' | 'mouse' | 'apps' | 'phrases' | 'graph' + +const INSIGHT_TABS: readonly InsightsTab[] = ['overview', 'keyboard', 'mouse', 'apps', 'phrases', 'graph'] + +const SECTION_TITLE_SX = { ...typoSx('label'), color: d3roPalette.text.tertiary } as const const HINT_SX = { color: d3roPalette.text.secondary, @@ -37,10 +48,9 @@ interface TileProps { label: string value: string unit?: string - emphasis?: boolean } -function StatTile({ label, value, unit, emphasis = false }: TileProps): React.ReactElement { +function StatTile({ label, value, unit }: TileProps): React.ReactElement { return ( - {label} - + {label} + {value} {unit ? ( - + {unit} ) : null} @@ -75,7 +84,7 @@ function TileRow({ children }: { children: React.ReactNode }): React.ReactElemen function Section({ title, children }: { title: string; children: React.ReactNode }): React.ReactElement { return ( - + {title} {children} @@ -97,12 +106,14 @@ interface SuggestionHistoryRow { export function InputInsightsView(): React.ReactElement { const { t, formatRelativeDate } = useI18n() const [days, setDays] = useState(7) - const [tab, setTab] = useState(0) + const [tab, setTab] = useState('overview') + const { hidden: hiddenPhrases, remove } = useDeferredDelete() const [history, setHistory] = useState([]) const [graph, setGraph] = useState(null) const [graphQuery, setGraphQuery] = useState('') const [graphResult, setGraphResult] = useState(null) - const { telemetry, summary, phrases, refresh } = useInputInsights(days) + const { telemetry, summary, phrases: allPhrases, loading, refresh } = useInputInsights(days) + const phrases = withoutHidden(allPhrases, hiddenPhrases) const loadHistory = useCallback(async (): Promise => { const result = await window.electronAPI.suggestion.getHistory({ limit: 20 }) @@ -128,13 +139,14 @@ export function InputInsightsView(): React.ReactElement { if (result.success) setGraphResult(result.data) }, [graphQuery]) - const handleDeletePhrase = useCallback( - async (id: string): Promise => { - await window.electronAPI.inputTelemetry.deletePhrase({ id }) - await refresh() - }, - [refresh] - ) + // 한 건 삭제는 "삭제했습니다 · 되돌리기" — 알림이 닫힐 때 실제로 지운다 + const handleDeletePhrase = (id: string): void => { + remove({ + id, + commit: async () => (await window.electronAPI.inputTelemetry.deletePhrase({ id })).success, + onCommitted: () => void refresh() + }) + } const dailyKeys: BarDatum[] = (summary?.daily ?? []).map((day) => ({ label: day.date.slice(5), @@ -148,11 +160,14 @@ export function InputInsightsView(): React.ReactElement { hint: `${day.date} · ${day.words.toLocaleString()}` })) - const dailyDistance: BarDatum[] = (summary?.daily ?? []).map((day) => ({ - label: day.date.slice(5), - value: Math.round(day.mouseDistancePx / 96 / 0.0254), - hint: `${day.date} · ${Math.round(day.mouseDistancePx)}px` - })) + const dailyDistance: BarDatum[] = (summary?.daily ?? []).map((day) => { + const meters = Math.round(pixelsToMeters(day.mouseDistancePx) * 10) / 10 + return { + label: day.date.slice(5), + value: meters, + hint: `${day.date} · ${meters.toLocaleString()} m` + } + }) const hourlyKeys: BarDatum[] = (summary?.hourly ?? []).map((hour) => ({ label: String(hour.hour), @@ -175,10 +190,22 @@ export function InputInsightsView(): React.ReactElement { : t('input.friction.band.steady') const topAppTotal = (summary?.topApps ?? []).reduce((sum, app) => sum + app.keystrokes, 0) - if (!telemetry?.enabled) { + // 상태를 읽기 전에는 '꺼져 있음'이라 쓰지 않는다 + if (!telemetry) { return ( - + + {loading ? t('common.loading') : t('tools.insights.loadFailed')} + + ) + } + + if (!telemetry.enabled) { + return ( + {t('input.insights.disabledHint')} + ) } @@ -186,7 +213,7 @@ export function InputInsightsView(): React.ReactElement { return ( - + {t('input.insights.rangeLabel')} {[7, 14, 30].map((value) => ( @@ -194,17 +221,21 @@ export function InputInsightsView(): React.ReactElement { key={value} component="button" type="button" + aria-pressed={value === days} + aria-describedby="insights-range-label" onClick={() => setDays(value)} sx={{ cursor: 'pointer', px: 1, py: 0.25, borderRadius: d3roRadius.xs, - border: `1px solid ${value === days ? d3roPalette.accent.main : d3roPalette.border.subtle}`, - bgcolor: value === days ? d3roPalette.accent.dim : 'transparent', - color: value === days ? d3roPalette.accent.main : d3roPalette.text.inactive, + border: `1px solid ${value === days ? d3roPalette.accent.main : d3roPalette.border.default}`, + bgcolor: 'transparent', + color: value === days ? d3roPalette.accent.light : d3roPalette.text.secondary, font: 'inherit', - ...typoSx('micro') + ...typoSx('small'), + transition: d3roTransition('border-color', 'color'), + '&:focus-visible': d3roFocusRing }} > {t('input.insights.rangeDays', { days: value })} @@ -221,36 +252,22 @@ export function InputInsightsView(): React.ReactElement { setTab(next)} + onChange={(_event, next: InsightsTab) => setTab(next)} variant="scrollable" scrollButtons={false} - sx={{ - minHeight: 36, - borderBottom: `1px solid ${d3roPalette.border.subtle}`, - '& .MuiTab-root': { - minHeight: 36, - py: 0.5, - textTransform: 'none', - color: d3roPalette.text.inactive, - fontSize: d3roTypo.label.size, - '&.Mui-selected': { color: d3roPalette.accent.main } as const - }, - '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 } - }} + aria-label={t('input.kbView.insights')} + sx={TOOL_TABS_SX} > - - - - - - + {INSIGHT_TABS.map((value) => ( + + ))} {/* ── 요약 ─────────────────────────────────────── */} - {tab === 0 ? ( + {tab === 'overview' ? ( - + @@ -288,10 +305,10 @@ export function InputInsightsView(): React.ReactElement { ) : null} {/* ── 키보드 ───────────────────────────────────── */} - {tab === 1 ? ( + {tab === 'keyboard' ? ( - + @@ -348,11 +365,11 @@ export function InputInsightsView(): React.ReactElement { - + {t('input.insights.topHoursHint', { hours: (summary?.topHours ?? []) .slice(0, 3) - .map((hour) => `${hour.hour}시`) + .map((hour) => t('input.flow.hour', { hour: hour.hour })) .join(', ') })} @@ -360,15 +377,15 @@ export function InputInsightsView(): React.ReactElement { ) : null} {/* ── 마우스 ───────────────────────────────────── */} - {tab === 2 ? ( + {tab === 'mouse' ? ( - + @@ -377,14 +394,14 @@ export function InputInsightsView(): React.ReactElement { - + {t('input.insights.mouseNote')} ) : null} {/* ── 앱 ──────────────────────────────────────── */} - {tab === 3 ? ( + {tab === 'apps' ? ( {t('input.insights.appsHint')} {(summary?.topApps ?? []).length === 0 ? ( @@ -452,12 +469,12 @@ export function InputInsightsView(): React.ReactElement { ) : null} {/* ── 개인 그래프 ──────────────────────────────── */} - {tab === 5 ? ( + {tab === 'graph' ? ( {t('input.graph.description')} - + @@ -483,7 +500,7 @@ export function InputInsightsView(): React.ReactElement {
{graphResult.anchors.map((anchor) => ( - + {anchor.text} {graphResult.neighbors @@ -518,7 +535,7 @@ export function InputInsightsView(): React.ReactElement { border: `1px solid ${d3roPalette.border.subtle}` }} > - + {edge.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')} @@ -532,7 +549,7 @@ export function InputInsightsView(): React.ReactElement { textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...typoSx('micro'), - color: d3roPalette.text.inactive + color: d3roPalette.text.tertiary }} > {edge.weight}× @@ -548,7 +565,7 @@ export function InputInsightsView(): React.ReactElement { {node.text} - + {node.count}× · {node.appName ?? '—'} @@ -558,7 +575,7 @@ export function InputInsightsView(): React.ReactElement { ) : null} {/* ── 문구 · 제안 ─────────────────────────────── */} - {tab === 4 ? ( + {tab === 'phrases' ? (
@@ -598,15 +615,15 @@ export function InputInsightsView(): React.ReactElement { {entry.accepted ? t('input.insights.accepted') : t('input.insights.notAccepted')} - + {entry.appName ?? '—'} - + {entry.latencyMs === null ? '' : `${entry.latencyMs}ms`} @@ -668,7 +685,7 @@ export function InputInsightsView(): React.ReactElement { textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...typoSx('micro'), - color: d3roPalette.text.inactive + color: d3roPalette.text.tertiary }} > {t('input.phrases.metadata', { @@ -680,19 +697,22 @@ export function InputInsightsView(): React.ReactElement { void handleDeletePhrase(phrase.id)} + onClick={() => handleDeletePhrase(phrase.id)} sx={{ flexShrink: 0 }} > - + )) )}
- - {t('input.insights.samplesNote', { count: summary?.sampleCount ?? 0 })}{' '} - {t('input.insights.collectedAt', { at: formatRelativeDate(telemetry.lastSnapshotAt || Date.now()) })} + + {t('input.insights.samplesNote', { count: summary?.sampleCount ?? 0 })} + {/* 수집한 적이 없으면 '방금 수집'처럼 보이지 않게 시각을 쓰지 않는다 */} + {telemetry.lastSnapshotAt > 0 + ? ` ${t('input.insights.collectedAt', { at: formatRelativeDate(telemetry.lastSnapshotAt) })}` + : ''}
) : null} diff --git a/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx b/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx index 7a47769..4ed4b58 100644 --- a/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx +++ b/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx @@ -2,11 +2,12 @@ // 액션 하나의 바인딩 목록 전체를 담당한다. 앱에서 키바인딩을 다루는 유일한 진입점이다. // // 라벨 · 설명 · 기본값은 KEYBINDING_ACTIONS 에서 파생하므로 호출부는 actionId 만 넘긴다. +// v4(2026-09-28): 설정 행 문법 — 왼쪽 라벨·설명, 오른쪽 키캡·추가·기본값. 카드 배경·인셋 그림자 없음. import { useCallback, useState } from 'react' import { Box, IconButton, Tooltip, Typography } from '@mui/material' import { Plus, RotateCcw, X } from 'lucide-react' -import { d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, d3roTransition, d3roTypo, typoSx } from '@d3ro/ui/theme' import { TactileBadge } from '@d3ro/ui/components/ds' import { useI18n } from '@d3ro/i18n' import type { KeyBinding, KeyBindingActionId } from '@d3ro/core/keybinding' @@ -61,49 +62,39 @@ export function KeyBindingField({ actionId }: KeyBindingFieldProps): React.React if (spec === null) return null + const label = t(asTranslationKey(spec.labelKey)) + return ( - - - {t(asTranslationKey(spec.labelKey))} + + + {label} + {spec.holdMode && {t('keybinding.ui.holdMode')}} + {spec.doublePress && {t('keybinding.ui.doublePress')}} + + + {t(asTranslationKey(spec.descriptionKey))} - {spec.holdMode && {t('keybinding.ui.holdMode')}} - {spec.doublePress && {t('keybinding.ui.doublePress')}} - - - - - + {failed && ( + + {t('keybinding.ui.saveFailed')} + + )} - - {t(asTranslationKey(spec.descriptionKey))} - - - + {bindings.length === 0 && ( - + {t('keybinding.ui.noBindings')} )} @@ -111,14 +102,7 @@ export function KeyBindingField({ actionId }: KeyBindingFieldProps): React.React handleRemove(binding)} aria-label={t('keybinding.ui.remove', { keys: bindingLabel(binding) })} - sx={{ color: d3roPalette.text.inactive, p: 0.25 }} + sx={{ color: d3roPalette.text.tertiary, p: 0.25 }} > ))} - setPickerOpen(true)} - aria-label={t('keybinding.ui.add')} + aria-label={`${label} — ${t('keybinding.ui.add')}`} sx={{ - color: d3roPalette.accent.main, - border: `1px dashed ${d3roPalette.border.strong}`, - borderRadius: d3roRadius.xs, + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + height: 24, px: 1, - height: 22, + border: `1px solid ${d3roPalette.border.default}`, + borderRadius: d3roRadius.xs, + bgcolor: 'transparent', + color: d3roPalette.text.secondary, + fontFamily: 'inherit', + fontSize: d3roTypo.small.size, + cursor: 'pointer', + transition: d3roTransition('color', 'border-color'), + '&:hover': { color: d3roPalette.text.primary, borderColor: d3roPalette.border.strong }, + '&:focus-visible': { outline: `2px solid ${d3roPalette.accent.main}`, outlineOffset: '2px' }, }} > - - {t('keybinding.ui.add')} - - - + {t('keybinding.ui.add')} + - {failed && ( - - {t('keybinding.ui.saveFailed')} - - )} + + + + + + > = { - 0: MouseButton.Left, - 1: MouseButton.Middle, - 2: MouseButton.Right, - 3: MouseButton.Back, - 4: MouseButton.Forward, -} - const GROUP_RANK: ReadonlyMap = new Map( KEY_CATALOG_GROUP_ORDER.map((group, index) => [group, index]) ) @@ -87,35 +73,6 @@ function byGroupOrder(a: KeyCatalogEntry, b: KeyCatalogEntry): number { const CATALOG_OPTIONS: readonly KeyCatalogEntry[] = [...KEY_CATALOG].sort(byGroupOrder) -/** - * 브라우저 KeyboardEvent 의 keyCode 를 Windows VK 로 맞춘다. - * - * keyCode 는 수정자를 좌/우 구분 없이 16/17/18 로 주는데 카탈로그는 좌/우 코드만 담고 있다. - * location 으로 보정하지 않으면 수정자 단독 바인딩이 전부 unknown-key 로 거부된다. - */ -function resolveKeyCode(event: KeyboardEvent): number { - const code = event.keyCode || event.which - const right = event.location === 2 - if (code === 16) return right ? VK.ShiftRight : VK.ShiftLeft - if (code === 17) return right ? VK.CtrlRight : VK.CtrlLeft - if (code === 18) return right ? VK.AltRight : VK.AltLeft - // macOS 오른쪽 Command 는 93 으로 온다 (Windows 의 93 은 ContextMenu 라 구분이 필요하다). - if (code === 93 && right) return VK.MetaRight - return code -} - -/** - * 눌린 키들 → 바인딩. - * - * 주 키는 수정자가 아닌 키가 있으면 그것, 없으면 **가장 나중에 누른** 수정자다. - * "Ctrl 누른 채 오른쪽 Alt" 가 `Ctrl + Right Alt` 로 잡히려면 마지막 것이어야 한다. - */ -function buildBinding(codes: readonly number[], mods: ModifierState): KeyBinding | null { - if (codes.length === 0) return null - const main = codes.find((code) => !isModifierKeyCode(code)) ?? codes[codes.length - 1] - return normalizeBinding({ device: 'keyboard', code: main, ...mods }) -} - function probeBinding(slot: ModifierSlot): KeyBinding { return { device: 'keyboard', @@ -192,6 +149,10 @@ export function KeyBindingPicker({ useEffect(() => clearCommitTimer, [clearCommitTimer]) + // 피커가 열려 있는 동안 전역 uiohook 트리거를 멈춘다. 이미 걸린 조합을 녹화하려고 누를 때 + // 그 액션(캡션 · 핸즈프리 · 받아쓰기 · 팝업)이 main 에서 실행되지 않게 한다. + useKeyBindingCaptureSuspension(open) + // ── 키보드 녹화 ──────────────────────────────────────── const handleKeyDown = useCallback( (event: KeyboardEvent) => { @@ -369,12 +330,10 @@ export function KeyBindingPicker({ zIndex: 1, px: 1.5, py: 0.75, - bgcolor: d3roPalette.bg.inset, - color: d3roPalette.text.label, - fontSize: d3roTypo.engrave.size, + bgcolor: d3roPalette.bg.elevated, + color: d3roPalette.text.tertiary, + fontSize: d3roTypo.label.size, fontWeight: 500, - letterSpacing: '0.1em', - textTransform: 'uppercase', borderBottom: `1px solid ${d3roPalette.border.subtle}`, }} > @@ -399,16 +358,8 @@ export function KeyBindingPicker({ data-testid="keybinding-picker" disableEnforceFocus disableRestoreFocus - PaperProps={{ - sx: { - bgcolor: d3roPalette.bg.chassis, - backgroundImage: 'none', - border: `1px solid ${d3roPalette.border.subtle}`, - borderRadius: d3roRadius.card, - }, - }} > - + {t('keybinding.ui.pickerTitle', { action: actionName })} @@ -436,9 +387,9 @@ export function KeyBindingPicker({ sx={{ border: `2px solid ${ rejectionKey !== null - ? d3roPalette.tag.red + ? d3roPalette.status.danger : recorded !== null - ? d3roPalette.tag.green + ? d3roPalette.status.success : preview !== null ? d3roPalette.accent.main : d3roPalette.border.strong @@ -454,7 +405,7 @@ export function KeyBindingPicker({ gap: 1.25, cursor: 'pointer', userSelect: 'none', - transition: 'border-color 0.2s ease', + transition: d3roTransition('border-color'), }} > {candidate !== null ? ( @@ -463,10 +414,10 @@ export function KeyBindingPicker({ ) : ( <> - + - + {t('keybinding.ui.modifiers')} @@ -507,6 +451,7 @@ export function KeyBindingPicker({ setListMods((prev) => ({ ...prev, [slot]: !prev[slot] }))} + aria-pressed={active} sx={{ borderRadius: d3roRadius.xs, px: 1.25, @@ -515,13 +460,12 @@ export function KeyBindingPicker({ fontSize: d3roTypo.small.size, fontWeight: 500, letterSpacing: '0.04em', - color: active ? d3roPalette.text.primary : d3roPalette.text.inactive, - bgcolor: active ? d3roPalette.accent.dim : d3roPalette.bg.chassis, + color: active ? d3roPalette.text.primary : d3roPalette.text.secondary, + bgcolor: active ? d3roPalette.accent.dim : 'transparent', border: `1px solid ${ active ? d3roPalette.accent.main : d3roPalette.border.default }`, - boxShadow: active ? d3roShadow.inset : 'none', - transition: 'all 0.15s ease', + transition: d3roTransition('color', 'background-color', 'border-color'), }} > {modifierLabel(slot, platform)} @@ -531,7 +475,7 @@ export function KeyBindingPicker({ {t('keybinding.ui.selectedKey')} @@ -584,7 +528,7 @@ export function KeyBindingPicker({ {option.disabledReasonKey !== undefined && ( {t(asTranslationKey(option.disabledReasonKey))} @@ -605,11 +549,11 @@ export function KeyBindingPicker({ popper: { placement: 'bottom-start', modifiers: [{ name: 'flip', enabled: false }] }, paper: { sx: { - bgcolor: d3roPalette.bg.chassis, + bgcolor: d3roPalette.bg.elevated, backgroundImage: 'none', border: `1px solid ${d3roPalette.border.default}`, borderRadius: d3roRadius.inner, - boxShadow: d3roShadow.card, + boxShadow: d3roShadow.overlay, }, }, listbox: { sx: { maxHeight: 260, py: 0 } }, @@ -622,7 +566,7 @@ export function KeyBindingPicker({ {rejectionKey !== null && ( {t(asTranslationKey(rejectionKey))} @@ -630,7 +574,7 @@ export function KeyBindingPicker({ {isDuplicate && ( {t('keybinding.ui.duplicate')} @@ -641,7 +585,7 @@ export function KeyBindingPicker({ {t('keybinding.ui.conflictWith', { action: spec === null ? conflict.actionId : t(asTranslationKey(spec.labelKey)), @@ -650,12 +594,12 @@ export function KeyBindingPicker({ ) })} {rejectionKey === null && warningKey !== null && ( - + {t(asTranslationKey(warningKey))} )} {mode === 'record' && candidate === null && ( - + {t('keybinding.ui.recordHint')} )} diff --git a/apps/desktop/src/renderer/components/keybinding/Keycap.tsx b/apps/desktop/src/renderer/components/keybinding/Keycap.tsx index fc7ef76..ff6df97 100644 --- a/apps/desktop/src/renderer/components/keybinding/Keycap.tsx +++ b/apps/desktop/src/renderer/components/keybinding/Keycap.tsx @@ -5,7 +5,7 @@ // 여기서는 그리기만 한다. import { Box } from '@mui/material' -import { d3roFontMono, d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme' +import { d3roFontMono, d3roFontSans, d3roPalette, d3roRadius, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { BindingSegment, KeyBinding } from '@d3ro/core/keybinding' import { formatBindingSegments, joinBindingSegments } from '@d3ro/core/keybinding' @@ -27,8 +27,12 @@ interface KeycapProps { muted?: boolean } +/** 모노 폰트는 라틴 키 이름에만 — 한글("마우스 왼쪽 버튼")은 모노에 글리프가 없어 자간이 벌어진다. */ +const NON_LATIN = /[^\u0000-\u024f]/ + export function Keycap({ children, size = 'md', muted = false }: KeycapProps): React.ReactElement { const spec = SIZE_SPEC[size] + const latin = typeof children !== 'string' || !NON_LATIN.test(children) return ( 0 && showSeparator && ( + diff --git a/apps/desktop/src/renderer/components/keybinding/keybinding-capture.ts b/apps/desktop/src/renderer/components/keybinding/keybinding-capture.ts new file mode 100644 index 0000000..870bd58 --- /dev/null +++ b/apps/desktop/src/renderer/components/keybinding/keybinding-capture.ts @@ -0,0 +1,84 @@ +// apps/desktop/src/renderer/components/keybinding/keybinding-capture.ts +// 피커가 바인딩을 녹화하는 동안 전역 키바인딩 트리거를 잠시 멈춘다. +// +// 피커는 렌더러 window 의 keydown/keyup 만 듣지만 KeyBindingService 의 uiohook 는 OS 전역이다. +// 이미 걸려 있는 조합(캡션 토글 · 핸즈프리 · 받아쓰기 마우스 버튼 · 팝업)을 녹화하려고 누르면 +// 녹화와 동시에 그 액션이 main 에서 실행돼 버린다. 녹화 중에는 main 에 캡처 모드를 알려 +// 트리거 방출만 막는다 (훅은 수정자 상태를 따라가야 하므로 계속 돈다). +// +// 전역 on/off(`keybinding.setEnabled`)는 쓰지 않는다 — hotkeyEnabled 설정을 저장하므로 +// 피커가 닫히기 전에 창이 죽으면 사용자의 단축키가 꺼진 채로 남는다. + +import { useEffect } from 'react' + +/** 캡처 모드 IO 포트 — preload `keybinding.beginCapture/endCapture` 가 구현한다. */ +export interface KeyBindingCapturePort { + beginCapture(): Promise + endCapture(): Promise +} + +type CaptureMethods = Partial> + +/** + * preload keybinding API 에서 캡처 포트를 찾는다. 노출돼 있지 않으면 null. + * + * 이 모듈이 preload 타입에 직접 묶이지 않도록 구조로 판별한다. + */ +export function resolveCapturePort(api: object | null | undefined): KeyBindingCapturePort | null { + if (api === null || api === undefined) return null + const methods = api as CaptureMethods + const begin = methods.beginCapture + const end = methods.endCapture + if (typeof begin !== 'function' || typeof end !== 'function') return null + return { + beginCapture: () => Promise.resolve(begin.call(api)), + endCapture: () => Promise.resolve(end.call(api)), + } +} + +/** + * 한 번의 녹화 구간. begin/end 는 각각 최대 한 번씩만 main 에 도달하고, + * end 는 begin 을 보낸 경우에만 보낸다. IPC 실패는 녹화 UI 를 막지 않는다. + */ +export class KeyBindingCaptureSession { + private active = false + + constructor(private readonly port: KeyBindingCapturePort | null) {} + + get isActive(): boolean { + return this.active + } + + begin(): void { + if (this.active || this.port === null) return + this.active = true + void this.port.beginCapture().catch(() => undefined) + } + + end(): void { + if (!this.active || this.port === null) return + this.active = false + void this.port.endCapture().catch(() => undefined) + } +} + +/** 기본 포트 — 렌더러 전역 preload API 에서 찾는다. */ +function defaultCapturePort(): KeyBindingCapturePort | null { + if (typeof window === 'undefined') return null + return resolveCapturePort(window.electronAPI?.keybinding) +} + +/** + * `active` 인 동안 전역 키바인딩 트리거를 멈춘다. 닫힘 · 언마운트 시 반드시 되돌린다. + */ +export function useKeyBindingCaptureSuspension( + active: boolean, + resolvePort: () => KeyBindingCapturePort | null = defaultCapturePort +): void { + useEffect(() => { + if (!active) return + const session = new KeyBindingCaptureSession(resolvePort()) + session.begin() + return () => session.end() + }, [active, resolvePort]) +} diff --git a/apps/desktop/src/renderer/components/keybinding/keybinding-recording.ts b/apps/desktop/src/renderer/components/keybinding/keybinding-recording.ts new file mode 100644 index 0000000..f1b46fa --- /dev/null +++ b/apps/desktop/src/renderer/components/keybinding/keybinding-recording.ts @@ -0,0 +1,59 @@ +// apps/desktop/src/renderer/components/keybinding/keybinding-recording.ts +// 녹화 탭의 순수 규칙 — 브라우저 입력 이벤트를 바인딩으로 옮긴다. +// +// React · DOM 에 묶이지 않도록 이벤트는 구조 타입으로만 받는다. + +import type { KeyBinding } from '@d3ro/core/keybinding' +import { MouseButton, VK, isModifierKeyCode, normalizeBinding } from '@d3ro/core/keybinding' + +export interface ModifierState { + ctrl: boolean + alt: boolean + shift: boolean + meta: boolean +} + +export const NO_MODIFIERS: ModifierState = { ctrl: false, alt: false, shift: false, meta: false } + +/** + * 브라우저 MouseEvent.button → libuiohook MOUSE_BUTTON 코드. + * 두 체계의 가운데/오른쪽 순서가 서로 다르므로 그대로 쓰면 안 된다. + */ +export const BROWSER_BUTTON_TO_MOUSE: Readonly> = { + 0: MouseButton.Left, + 1: MouseButton.Middle, + 2: MouseButton.Right, + 3: MouseButton.Back, + 4: MouseButton.Forward, +} + +export type KeyCodeSource = Pick + +/** + * 브라우저 KeyboardEvent 의 keyCode 를 Windows VK 로 맞춘다. + * + * keyCode 는 수정자를 좌/우 구분 없이 16/17/18 로 주는데 카탈로그는 좌/우 코드만 담고 있다. + * location 으로 보정하지 않으면 수정자 단독 바인딩이 전부 unknown-key 로 거부된다. + */ +export function resolveKeyCode(event: KeyCodeSource): number { + const code = event.keyCode || event.which + const right = event.location === 2 + if (code === 16) return right ? VK.ShiftRight : VK.ShiftLeft + if (code === 17) return right ? VK.CtrlRight : VK.CtrlLeft + if (code === 18) return right ? VK.AltRight : VK.AltLeft + // macOS 오른쪽 Command 는 93 으로 온다 (Windows 의 93 은 ContextMenu 라 구분이 필요하다). + if (code === 93 && right) return VK.MetaRight + return code +} + +/** + * 눌린 키들 → 바인딩. + * + * 주 키는 수정자가 아닌 키가 있으면 그것, 없으면 **가장 나중에 누른** 수정자다. + * "Ctrl 누른 채 오른쪽 Alt" 가 `Ctrl + Right Alt` 로 잡히려면 마지막 것이어야 한다. + */ +export function buildBinding(codes: readonly number[], mods: ModifierState): KeyBinding | null { + if (codes.length === 0) return null + const main = codes.find((code) => !isModifierKeyCode(code)) ?? codes[codes.length - 1] + return normalizeBinding({ device: 'keyboard', code: main, ...mods }) +} diff --git a/apps/desktop/src/renderer/components/meeting/DocumentTab.tsx b/apps/desktop/src/renderer/components/meeting/DocumentTab.tsx index ff2653a..0ffcf2c 100644 --- a/apps/desktop/src/renderer/components/meeting/DocumentTab.tsx +++ b/apps/desktop/src/renderer/components/meeting/DocumentTab.tsx @@ -1,13 +1,18 @@ // src/renderer/components/meeting/DocumentTab.tsx -// Phase 14.5: 문서 탭 — MarkdownEditor + 자동저장 + 내보내기 +// Phase 14.5: 문서 탭 — MarkdownEditor + 내보내기/삭제 +// 자동저장(디바운스)은 문서별로 useMeetingDocuments 가 맡는다. 이 탭은 입력을 즉시 올리고, +// 떠날 때(문서 전환·언마운트) onLeave 로 대기 중인 저장을 바로 실행하게 한다. +// 부모는 문서마다 key 를 달아 렌더링해야 한다. +// 삭제는 되돌릴 수 없으므로 ConfirmDialog 로 한 번 묻는다(예전엔 누르는 즉시 지웠다). -import { useCallback, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Box } from '@mui/material' import { Trash2 } from 'lucide-react' import { MarkdownEditor } from './MarkdownEditor' import { ExportMenu } from './ExportMenu' +import { ConfirmDialog } from '../shared' import { PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roTypo } from '@d3ro/ui/theme' +import { d3roPalette } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { MeetingExportFormat } from '@d3ro/core/types' @@ -20,31 +25,44 @@ interface DocumentTabDoc { interface DocumentTabProps { document: DocumentTabDoc + /** 화면에 보이는 문서 이름(제목이 비었을 때의 대체 이름 포함) */ + displayTitle: string + /** 입력마다 즉시 호출된다(저장 디바운스는 호출 쪽 책임). */ onContentChange: (content: string) => void + /** 탭을 떠날 때 한 번 호출된다. */ + onLeave?: () => void onExport: (format: MeetingExportFormat) => void - onDelete: () => void + /** 확인을 거친 뒤에만 불린다. */ + onDelete: () => void | Promise } -const AUTOSAVE_DELAY = 500 - export function DocumentTab({ document: doc, + displayTitle, onContentChange, + onLeave, onExport, onDelete, }: DocumentTabProps): React.ReactElement { const { t } = useI18n() - const timerRef = useRef | null>(null) + const onLeaveRef = useRef(onLeave) + useEffect(() => { + onLeaveRef.current = onLeave + }) + useEffect(() => () => onLeaveRef.current?.(), []) - const handleChange = useCallback( - (content: string) => { - if (timerRef.current) clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => { - onContentChange(content) - }, AUTOSAVE_DELAY) - }, - [onContentChange], - ) + const [confirmOpen, setConfirmOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + + const handleConfirmDelete = async (): Promise => { + setDeleting(true) + try { + await onDelete() + } finally { + setDeleting(false) + setConfirmOpen(false) + } + } return ( {/* 콘텐츠 편집기 */} - {/* 하단 액션 바 */} + {/* 하단 행동 — 이 문서에만 해당한다 */} setConfirmOpen(true)} + startIcon={} > - {t('common.delete')} + + void handleConfirmDelete()} + onClose={() => setConfirmOpen(false)} + /> ) } diff --git a/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx b/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx index 28fd81a..bcdbe13 100644 --- a/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx +++ b/apps/desktop/src/renderer/components/meeting/EditableSegment.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useCallback } from 'react' import { Box, TextField, Tooltip, Chip } from '@mui/material' -import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme' +import { d3roPalette, d3roFontMono, d3roFontSans, d3roRadius, d3roTransition, d3roFocusRing, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { isImeComposingEvent } from '../../utils/keyboard' @@ -100,7 +100,7 @@ export function EditableSegment({ sx={{ fontFamily: d3roFontMono, fontSize: 11, - color: d3roPalette.text.inactive, + color: d3roPalette.text.tertiary, pt: '7px', flexShrink: 0, userSelect: 'none', @@ -120,8 +120,8 @@ export function EditableSegment({ multiline sx={{ '& .MuiInputBase-root': { - fontFamily: d3roFontMono, - fontSize: 13, + fontFamily: d3roFontSans, + ...typoSx('body'), bgcolor: d3roPalette.bg.input, }, }} @@ -134,21 +134,32 @@ export function EditableSegment({ { + if (readOnly || isImeComposingEvent(e)) return + if (e.key === 'Enter') { + e.preventDefault() + handleClick() + } + }} sx={{ - fontFamily: d3roFontMono, - fontSize: 13, + fontFamily: d3roFontSans, + ...typoSx('body'), color: d3roPalette.text.primary, cursor: readOnly ? 'default' : 'text', - borderBottom: edited ? `1px dashed ${d3roPalette.text.inactive}` : 'none', - borderRadius: '2px', + borderBottom: edited ? `1px dashed ${d3roPalette.text.tertiary}` : 'none', + borderRadius: d3roRadius.xs, + transition: d3roTransition('background-color'), px: readOnly ? 0 : '2px', '&:hover': readOnly ? {} : { color: d3roPalette.text.primary, borderBottomColor: edited ? d3roPalette.accent.main : undefined, - bgcolor: edited ? undefined : d3roPalette.bg.elevated, + bgcolor: edited ? undefined : d3roPalette.surface.overlaySoft, }, + '&:focus-visible': d3roFocusRing, }} > {text} @@ -171,7 +182,7 @@ export function EditableSegment({ sx={{ fontFamily: d3roFontMono, fontSize: 11, - color: d3roPalette.text.inactive, + color: d3roPalette.text.tertiary, pt: '2px', flexShrink: 0, userSelect: 'none', @@ -190,7 +201,7 @@ export function EditableSegment({ bgcolor: `${speakerColor}22`, color: speakerColor, border: `1px solid ${speakerColor}55`, - fontFamily: d3roFontMono, + fontFamily: d3roFontSans, }} /> )} diff --git a/apps/desktop/src/renderer/components/meeting/ExportMenu.tsx b/apps/desktop/src/renderer/components/meeting/ExportMenu.tsx index 0d58341..b44470f 100644 --- a/apps/desktop/src/renderer/components/meeting/ExportMenu.tsx +++ b/apps/desktop/src/renderer/components/meeting/ExportMenu.tsx @@ -1,61 +1,86 @@ // src/renderer/components/meeting/ExportMenu.tsx -// Phase 14.5: 다운로드 형식 선택 드롭다운 +// 내보내기 메뉴 — 한 버튼 아래에 형식을 모은다. +// 두 가지로 쓴다: onExport 만 주면 문서 한 건의 네 형식(md/txt/pdf/docx), +// groups 를 주면 "요약 / 전사"처럼 대상별로 묶은 항목(회의 상세의 행동 줄). import { useState, useCallback } from 'react' -import { Menu, MenuItem } from '@mui/material' -import { Download } from 'lucide-react' +import { ListSubheader, Menu, MenuItem, Typography } from '@mui/material' +import { ChevronDown, Download } from 'lucide-react' import { PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, d3roShadow, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import type { TranslationKey } from '@d3ro/i18n' import type { MeetingExportFormat } from '@d3ro/core/types' -interface ExportMenuProps { - onExport: (format: MeetingExportFormat) => void - onCopyToClipboard?: () => void -} - -interface FormatItem { - format: MeetingExportFormat +export interface ExportMenuItem { + key: string label: string + onSelect: () => void + disabled?: boolean } -export function ExportMenu({ onExport, onCopyToClipboard }: ExportMenuProps): React.ReactElement { +export interface ExportMenuGroup { + key: string + label: string + /** 그룹이 비활성일 때 이유 한 줄 */ + hint?: string + items: ExportMenuItem[] +} + +interface ExportMenuProps { + /** 단일 대상 모드: 고른 형식을 알린다 */ + onExport?: (format: MeetingExportFormat) => void + /** 묶음 모드: 대상별 항목 */ + groups?: ExportMenuGroup[] + disabled?: boolean +} + +export const EXPORT_FORMATS: readonly MeetingExportFormat[] = ['md', 'txt', 'pdf', 'docx'] + +export const EXPORT_FORMAT_LABEL_KEYS: Record = { + md: 'meetings.format.md', + txt: 'meetings.format.txt', + pdf: 'meetings.format.pdf', + docx: 'meetings.format.docx', +} + +export function ExportMenu({ onExport, groups, disabled = false }: ExportMenuProps): React.ReactElement { const { t } = useI18n() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) - const handleOpen = useCallback((e: React.MouseEvent) => { - setAnchorEl(e.currentTarget) - }, []) + const handleClose = useCallback(() => setAnchorEl(null), []) - const handleClose = useCallback(() => { - setAnchorEl(null) - }, []) + const formatLabel = (format: MeetingExportFormat): string => t(EXPORT_FORMAT_LABEL_KEYS[format]) - const handleSelect = useCallback( - (format: MeetingExportFormat) => { - onExport(format) - handleClose() - }, - [onExport, handleClose], - ) - - const formats: FormatItem[] = [ - { format: 'md', label: t('meeting.exportMd') }, - { format: 'txt', label: t('meeting.exportTxt') }, - { format: 'pdf', label: t('meeting.exportPdfFmt') }, - { format: 'docx', label: t('meeting.exportDocx') }, - ] + const resolvedGroups: ExportMenuGroup[] = + groups ?? + (onExport + ? [ + { + key: 'single', + label: '', + items: EXPORT_FORMATS.map((format) => ({ + key: format, + label: formatLabel(format), + onSelect: () => onExport(format), + })), + }, + ] + : []) return ( <> setAnchorEl(e.currentTarget)} + disabled={disabled} + aria-haspopup="menu" + aria-expanded={open} + startIcon={} + trailingIcon={} > - - {t('meeting.exportFormat')} + {t('meetings.export.label')} - {formats.map(({ format, label }) => ( - handleSelect(format)} - sx={{ - fontFamily: d3roFontMono, - fontSize: d3roTypo.compact.size, - color: d3roPalette.text.primary, - '&:hover': { - bgcolor: d3roPalette.bg.cardHover, - color: d3roPalette.accent.main, - }, - }} - > - {label} - - ))} - {onCopyToClipboard && ( - { onCopyToClipboard(); handleClose() }} - sx={{ - fontFamily: d3roFontMono, - fontSize: d3roTypo.compact.size, - color: d3roPalette.text.primary, - '&:hover': { - bgcolor: d3roPalette.bg.cardHover, - color: d3roPalette.accent.main, - }, - }} - > - {t('meeting.copyToClipboard')} - - )} + {resolvedGroups.flatMap((group) => [ + ...(group.label + ? [ + + {group.label} + , + ] + : []), + ...(group.hint + ? [ + + {group.hint} + , + ] + : []), + ...group.items.map((item) => ( + { + handleClose() + item.onSelect() + }} + sx={{ ...typoSx('compact'), color: d3roPalette.text.primary }} + > + {item.label} + + )), + ])} ) diff --git a/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx index a36ff1d..fd4cd2e 100644 --- a/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/meeting/MarkdownEditor.tsx @@ -1,11 +1,13 @@ // src/renderer/components/meeting/MarkdownEditor.tsx // Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트 +// 제어 컴포넌트다 — 본문은 항상 content prop 이고, 입력은 onChange 로만 올린다. +// (내부 draft 사본을 두면 늦게 도착한 저장 결과가 더 새로운 입력을 덮어쓴다.) -import { useState, useEffect, useCallback } from 'react' +import { useState, useCallback } from 'react' import { Box } from '@mui/material' import { MarkdownRenderer } from './MarkdownRenderer' import { PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme' +import { d3roPalette, d3roFontSans, d3roFocusRing, d3roRadius, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' interface MarkdownEditorProps { @@ -15,23 +17,16 @@ interface MarkdownEditorProps { } export function MarkdownEditor({ - content: initialContent, + content, onChange, readOnly = false, }: MarkdownEditorProps): React.ReactElement { const { t } = useI18n() const [preview, setPreview] = useState(true) - const [draft, setDraft] = useState(initialContent) - - useEffect(() => { - setDraft(initialContent) - }, [initialContent]) const handleTextChange = useCallback( (e: React.ChangeEvent) => { - const val = e.target.value - setDraft(val) - onChange(val) + onChange(e.target.value) }, [onChange], ) @@ -40,20 +35,20 @@ export function MarkdownEditor({ {/* 상단 토글 */} {!readOnly && ( - + setPreview(true)} - sx={{ height: 32, fontSize: d3roTypo.engrave.size }} > {t('meeting.previewMode')} setPreview(false)} - sx={{ height: 32, fontSize: d3roTypo.engrave.size }} > {t('meeting.editMode')} @@ -63,31 +58,29 @@ export function MarkdownEditor({ {/* 콘텐츠 영역 */} {preview || readOnly ? ( - + ) : ( )} diff --git a/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx index c6a0832..5271332 100644 --- a/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/components/meeting/MarkdownRenderer.tsx @@ -28,7 +28,7 @@ const components: Components = { href={href} rel="noopener noreferrer" onClick={(event: React.MouseEvent) => openLinkExternally(event, href)} - sx={{ color: d3roPalette.accent.main, textDecoration: 'underline', cursor: 'pointer' }} + sx={{ color: d3roPalette.accent.light, textDecoration: 'underline', cursor: 'pointer' }} > {children} @@ -38,8 +38,8 @@ const components: Components = { component="h1" sx={{ fontSize: d3roTypo.value.size, - fontWeight: 600, - color: d3roPalette.accent.main, + fontWeight: 500, + color: d3roPalette.text.primary, mt: 2, mb: 1, borderBottom: `1px solid ${d3roPalette.border.default}`, @@ -54,7 +54,7 @@ const components: Components = { component="h2" sx={{ fontSize: d3roTypo.heading.size, - fontWeight: 600, + fontWeight: 500, color: d3roPalette.text.primary, mt: 1.5, mb: 0.75, @@ -68,7 +68,7 @@ const components: Components = { component="h3" sx={{ fontSize: d3roTypo.body.size, - fontWeight: 600, + fontWeight: 500, color: d3roPalette.text.secondary, mt: 1, mb: 0.5, @@ -111,7 +111,7 @@ const components: Components = { sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, - color: d3roPalette.accent.main, + color: d3roPalette.text.primary, }} > {children} @@ -125,7 +125,7 @@ const components: Components = { sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, - color: d3roPalette.accent.main, + color: d3roPalette.text.primary, bgcolor: d3roPalette.bg.inset, px: 0.5, py: 0.25, @@ -150,9 +150,9 @@ const components: Components = { textAlign: 'left', }, '& th': { - bgcolor: d3roPalette.bg.chassis, + bgcolor: d3roPalette.bg.inset, color: d3roPalette.text.secondary, - fontWeight: 600, + fontWeight: 500, }, '& tr:nth-of-type(even)': { bgcolor: d3roPalette.bg.inset, @@ -195,7 +195,7 @@ const components: Components = { void } -const COLLAPSED_HEIGHT = 40 -const EXPANDED_HEIGHT = 240 +const HEADER_HEIGHT = 40 +const PANEL_HEIGHT = 280 /** LLM … 리즈닝 블록 제거 (스트리밍 중 열린 태그 포함) */ function stripThinkTags(text: string): string { @@ -64,13 +67,12 @@ function TypingIndicator(): React.ReactElement { ) } -export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.ReactElement { +export function MeetingChatPanel({ sessionId, onClose }: MeetingChatPanelProps): React.ReactElement { const { t } = useI18n() const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') const [streaming, setStreaming] = useState(false) const [streamingContent, setStreamingContent] = useState('') - const [collapsed, setCollapsed] = useState(false) const [error, setError] = useState(null) const scrollRef = useRef(null) const inputRef = useRef(null) @@ -183,10 +185,6 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re void window.electronAPI.meetingChat.clear({ sessionId }) }, [sessionId]) - const handleToggleCollapse = useCallback(() => { - setCollapsed((prev) => !prev) - }, []) - // 스트리밍 콘텐츠에서 think 태그 제거한 표시용 텍스트 const visibleStreamingContent = useMemo( () => stripThinkTags(streamingContent), @@ -199,14 +197,13 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re {/* 헤더 */} @@ -215,18 +212,15 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re display: 'flex', alignItems: 'center', px: 1.5, - height: COLLAPSED_HEIGHT, + height: HEADER_HEIGHT, flexShrink: 0, - borderBottom: collapsed ? 'none' : `1px solid ${d3roPalette.border.subtle}`, + borderBottom: `1px solid ${d3roPalette.border.subtle}`, gap: 1, }} > - - {t('meeting.chat')} - + + {t('meetings.ask')} + @@ -235,23 +229,19 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re onClick={handleClear} disabled={messages.length === 0 && !streaming} > - + - - - {collapsed ? ( - - ) : ( - - )} + + + - {!collapsed && ( + {( <> {/* 메시지 히스토리 */} diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 0e97fa5..efdb699 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -1,116 +1,163 @@ // src/renderer/components/meeting/MeetingDetailTabs.tsx -// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad) +// 회의 상세 — "회의록 도구"로 읽히게: 머리(제목·날짜·길이·상태) → 행동 줄 한 곳(요약 복사·전사 복사· +// 내보내기·삭제) → 탭 [요약 | 전사 | 문서 | 내 메모]. 요약이 가장 강한 덩어리이고 전사는 근거다. +// "질문하기"(회의 AI 채팅)는 탭 줄 오른쪽에 접어 둔다. +// 2026-09-28 개편: Split/Transcript/Minutes 보기 모드와 45/55 고정 분할, 오른쪽 탭 난립을 이 구조로 정리했다. +// 기능(문서 생성·편집·AI 다듬기·화자 구분·할 일 체크·질문하기)은 그대로 있다. -import React, { useState, useEffect, useCallback, useRef } from 'react' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Box, ButtonBase, IconButton, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material' +import { ArrowLeft, Check, Copy, MessageSquare, Pencil, Plus, Trash2, X } from 'lucide-react' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { + d3roFocusRing, + d3roPalette, + d3roRadius, + d3roTransition, + typoSx, +} from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { IPCResult } from '@d3ro/core/errors' +import type { + MeetingDocTemplate, + MeetingDocument, + MeetingExportFormat, + MeetingSessionDetail, +} from '@d3ro/core/types' import { useMeetingTranscript } from './useMeetingTranscript' -import { buildNotesEnhanceRequest } from './notes-enhancement' +import { useMeetingDocuments } from './useMeetingDocuments' +import { buildNotesEnhanceRequest, pickNotesEnhanceTemplateId } from './notes-enhancement' import { - Box, - Tabs, - Tab, - IconButton, - TextField, - Tooltip, - Typography, - Checkbox, - Snackbar, - Alert, -} from '@mui/material' -import { - ArrowLeft, - Plus, - Pencil, - Check, - Users, - Copy, - Sparkles, -} from 'lucide-react' + MEETING_DETAIL_TABS, + resolveSelectedDocumentId, + selectPrimaryActionItems, + type ActionItem, + type MeetingDetailTab, +} from './meeting-documents-model' +import { formatActionItemsForCopy, selectSummarySource, summaryCopyText } from './meeting-summary-model' +import { clearNotepad, defaultNotepadStorage } from './meeting-notepad-storage' import { TranscriptTab } from './TranscriptTab' import { DocumentTab } from './DocumentTab' import { AddDocumentDialog } from './AddDocumentDialog' import { MeetingChatPanel } from './MeetingChatPanel' -import { - PhosphorText, - PhysicalButton, - SegmentControl, - TactileBadge, - MetalCard, -} from '@d3ro/ui/components/ds' +import { MeetingSummaryTab } from './MeetingSummaryTab' +import { MeetingNotesTab } from './MeetingNotesTab' +import { EXPORT_FORMATS, EXPORT_FORMAT_LABEL_KEYS, ExportMenu, type ExportMenuGroup } from './ExportMenu' +import { ConfirmDialog, EmptyStateCard, useNotify } from '../shared' import { isImeComposingEvent } from '../../utils/keyboard' -import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme' -import { useI18n } from '@d3ro/i18n' -import type { - MeetingSessionDetail, - MeetingDocument, - MeetingDocTemplate, - MeetingExportFormat, -} from '@d3ro/core/types' +import { formatClockTime, formatDayLabel, formatDurationLong } from '../../utils/formatters' +import { meetingStatusLabelKey, meetingStatusTone, shouldShowStatusBadge } from './meeting-format' interface MeetingDetailTabsProps { detail: MeetingSessionDetail onBack: () => void + /** 삭제가 확정된 뒤 불린다(부모가 목록으로 돌아가 알린다). */ + onDeleted: (sessionId: string) => void } -type ViewMode = 'split' | 'transcript' | 'doc' +const TAB_LABEL_KEYS = { + summary: 'meetings.tab.summary', + transcript: 'meetings.tab.transcript', + documents: 'meetings.tab.documents', + notes: 'meetings.tab.notes', +} as const -// 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 +function DocumentChip({ + selected, + onClick, + children, +}: { + selected: boolean + onClick: () => void + children: React.ReactNode +}): React.ReactElement { + return ( + + + {children} + + + ) } export function MeetingDetailTabs({ detail: initialDetail, onBack, + onDeleted, }: MeetingDetailTabsProps): React.ReactElement { - const { t } = useI18n() + const { t, locale } = useI18n() + const notify = useNotify() const [detail, setDetail] = useState(initialDetail) - const [documents, setDocuments] = useState((initialDetail.documents ?? []).filter(Boolean)) + // 문서 목록·자동저장·삭제·액션 아이템 토글은 훅이 전담한다(문서 본문이 정본). + const docs = useMeetingDocuments(initialDetail.documents) + const { documents } = docs const [templates, setTemplates] = useState([]) - - // Tab key string state (e.g. `doc-${id}`, 'notepad', 'action-items') - const [activeTabKey, setActiveTabKey] = useState(() => { - const docs = (initialDetail.documents ?? []).filter(Boolean) - return docs.length > 0 ? `doc-${docs[0].id}` : 'notepad' - }) + + const [tab, setTab] = useState('summary') + // 문서 탭에서 고른 문서 — 사라지면 렌더링 시점에 첫 문서로 맞춘다. + const [pickedDocId, setPickedDocId] = useState(null) + const selectedDocId = resolveSelectedDocumentId(pickedDocId, documents) const [dialogOpen, setDialogOpen] = useState(false) + /** 문서 생성 다이얼로그를 연 곳 — 요약 탭에서 열었으면 만든 뒤에도 요약 탭에 머문다. */ + const dialogOriginRef = useRef('documents') const [generating, setGenerating] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(detail.title ?? '') - const [viewMode, setViewMode] = useState('split') - - // Granola-style Scratchpad & Action Items State - const [scratchpadNotes, setScratchpadNotes] = useState('') - const [actionItems, setActionItems] = useState>([]) const [enhancingNotes, setEnhancingNotes] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const [deleting, setDeleting] = useState(false) + // 질문하기: 한 번 열면 패널을 유지한다(닫아도 진행 중 답변을 끊지 않게 숨기기만 한다). + const [askOpen, setAskOpen] = useState(false) + const [askMounted, setAskMounted] = useState(false) + + // 액션 아이템은 1순위 문서 본문에서 파생한다(별도 사본 상태를 두지 않는다). + const primaryDoc = documents[0] + const actionItems = useMemo(() => selectPrimaryActionItems(documents), [documents]) + const summarySource = useMemo( + () => selectSummarySource(documents, detail.minutesMarkdown, detail.minutes), + [documents, detail.minutesMarkdown, detail.minutes], + ) + const summaryLabels = useMemo( + () => ({ decisions: t('meetings.summary.decisions'), actionItems: t('meetings.summary.actionItems') }), + [t], + ) + const summaryText = summaryCopyText(summarySource, summaryLabels) // 전사 보기 상태·편집 저장·AI 다듬기·화자 구분은 훅이 전담한다(보기 전환은 저장하지 않는다). const transcript = useMeetingTranscript(initialDetail) - const [enhanceError, setEnhanceError] = useState(null) + const hasTranscript = transcript.selectedText.trim().length > 0 + + // ── 오류 → 알림 한 곳 ── const transcriptErrorMessage = transcript.error ? transcript.error.kind === 'polishFailed' ? t('meeting.polishFailed') : transcript.error.message : null - const errorMessage = transcriptErrorMessage ?? enhanceError + const errorMessage = transcriptErrorMessage ?? docs.error const { clearError: clearTranscriptError } = transcript - const clearErrors = useCallback(() => { + const { clearError: clearDocumentsError } = docs + useEffect(() => { + if (!errorMessage) return + notify({ message: errorMessage, severity: 'error' }) clearTranscriptError() - setEnhanceError(null) - }, [clearTranscriptError]) + clearDocumentsError() + }, [errorMessage, notify, clearTranscriptError, clearDocumentsError]) // 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시) useEffect(() => { @@ -125,98 +172,165 @@ 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 - await window.electronAPI.meetingMode.updateTitle({ - sessionId: detail.id, - title: titleDraft.trim(), - }) - setDetail((prev) => ({ ...prev, title: titleDraft.trim() })) + const title = titleDraft.trim() + if (!title) return + let failure: string | null = null + try { + const resp = await window.electronAPI.meetingMode.updateTitle({ sessionId: detail.id, title }) + if (!resp.success) failure = resp.error.message + } catch (err: unknown) { + failure = err instanceof Error ? err.message : String(err) + } + if (failure !== null) { + notify({ message: t('meetings.renameFailed', { message: failure }), severity: 'error' }) + return + } + setDetail((prev) => ({ ...prev, title })) setEditingTitle(false) - }, [detail.id, titleDraft]) + }, [detail.id, titleDraft, notify, t]) - // ── 문서 내용 자동저장 ── - const handleDocContentChange = useCallback( - async (docId: string, content: string) => { - await window.electronAPI.meetingMode.updateDocument({ - documentId: docId, - content, - }) - setDocuments((prev) => - prev.map((d) => (d.id === docId ? { ...d, content } : d)), - ) + const cancelTitleEdit = useCallback(() => { + setTitleDraft(detail.title ?? '') + setEditingTitle(false) + }, [detail.title]) + + // ── 복사 ── (요약과 전사를 따로) + const copyText = useCallback( + async (text: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(text) + notify({ message: successMessage, severity: 'success' }) + } catch { + notify({ message: t('meetings.detail.copyFailed'), severity: 'error' }) + } }, - [], + [notify, t], ) - const isExportingRef = useRef(false) - const isDeletingDocRef = useRef(false) + // ── 액션 아이템 체크 ── (1순위 문서의 해당 소스 줄을 고쳐 저장한다) + const { toggleActionItem, remove: removeDocument, add: addDocument } = docs + const handleToggleActionItem = useCallback( + (item: ActionItem, done: boolean) => { + if (!primaryDoc) return + void toggleActionItem(primaryDoc.id, item.lineIndex, done) + }, + [primaryDoc, toggleActionItem], + ) - // ── 문서 내보내기 ── - const handleExportDocument = useCallback( - async (docId: string, format: MeetingExportFormat) => { + // ── 내보내기 ── (저장 대화상자를 취소하면 main 이 빈 경로를 돌려준다 — 알리지 않는다) + const isExportingRef = useRef(false) + const runExport = useCallback( + async (task: () => Promise>) => { if (isExportingRef.current) return isExportingRef.current = true try { - await window.electronAPI.meetingMode.exportDocument({ - documentId: docId, - format, - }) + const resp = await task() + if (!resp.success) { + notify({ message: t('meetings.export.failed', { message: resp.error.message }), severity: 'error' }) + } else if (resp.data) { + notify({ message: t('meetings.export.done', { path: resp.data }), severity: 'success' }) + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.export.failed', { message }), severity: 'error' }) } finally { isExportingRef.current = false } }, - [], + [notify, t], ) - // ── 문서 삭제 ── + const handleExportDocument = useCallback( + (docId: string, format: MeetingExportFormat) => + void runExport(() => window.electronAPI.meetingMode.exportDocument({ documentId: docId, format })), + [runExport], + ) + + const exportGroups = useMemo(() => { + const sessionId = detail.id + let summaryItems: ExportMenuGroup['items'] + if (summarySource.kind === 'document') { + const docId = summarySource.document.id + summaryItems = EXPORT_FORMATS.map((format) => ({ + key: format, + label: t(EXPORT_FORMAT_LABEL_KEYS[format]), + onSelect: () => handleExportDocument(docId, format), + })) + } else { + // 동기화된 회의록(minutesMarkdown·minutes)은 회의 단위 내보내기(md·pdf)만 있다. + const sessionLevel = summarySource.kind !== 'none' + summaryItems = EXPORT_FORMATS.map((format) => ({ + key: format, + label: t(EXPORT_FORMAT_LABEL_KEYS[format]), + disabled: !sessionLevel || (format !== 'md' && format !== 'pdf'), + onSelect: () => + void runExport(() => + format === 'pdf' + ? window.electronAPI.meetingMode.exportPdf({ sessionId }) + : window.electronAPI.meetingMode.exportMarkdown({ sessionId }), + ), + })) + } + return [ + { + key: 'summary', + label: t('meetings.export.summaryGroup'), + hint: summarySource.kind === 'none' ? t('meetings.export.noSummary') : undefined, + items: summaryItems, + }, + { + key: 'transcript', + label: t('meetings.export.transcriptGroup'), + items: [ + { + key: 'txt', + label: t('meetings.format.txt'), + disabled: !hasTranscript, + onSelect: () => void runExport(() => window.electronAPI.meetingMode.exportTranscript({ sessionId })), + }, + ], + }, + ] + }, [detail.id, summarySource, hasTranscript, handleExportDocument, runExport, t]) + + // ── 문서 삭제 ── (확정된 뒤에만 목록에서 빠지고, 선택은 resolveSelectedDocumentId 가 맞춘다) + const isDeletingDocRef = useRef(false) const handleDeleteDocument = useCallback( async (docId: string) => { 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 - }) + const result = await removeDocument(docId) + if (result.status === 'saved') notify({ message: t('meetings.doc.deleted'), severity: 'success' }) } finally { isDeletingDocRef.current = false } }, - [], + [removeDocument, notify, t], ) + // ── 회의 삭제 ── (되돌릴 수 없다 — ConfirmDialog 를 거친다) + const handleDeleteMeeting = useCallback(async () => { + setDeleting(true) + try { + const resp = await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id }) + if (!resp.success) { + notify({ message: t('meetings.deleteFailed', { message: resp.error.message }), severity: 'error' }) + return + } + clearNotepad(defaultNotepadStorage(), detail.id) + setConfirmDelete(false) + onDeleted(detail.id) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.deleteFailed', { message }), severity: 'error' }) + } finally { + setDeleting(false) + } + }, [detail.id, notify, onDeleted, t]) + // ── 화자 구분 ── (전사 갱신은 훅이, 세션의 나머지 필드 갱신은 여기서) const { diarize } = transcript const handleDiarize = useCallback(async () => { @@ -224,401 +338,466 @@ export function MeetingDetailTabs({ if (updated) setDetail(updated) }, [diarize]) - // ── Granola "Jot & Enhance" AI Note Enhancement ── - // main 의 generateDocument 한 번으로 생성한다(전사·메모는 main 이 직접 읽는다). - const handleEnhanceScratchpad = useCallback(async () => { - if (enhancingNotes) return - const request = buildNotesEnhanceRequest({ - sessionId: detail.id, - notes: scratchpadNotes, - templates, - title: '📝 내 메모 기반 회의록 (AI 증강)', - }) - if (!request) return - setEnhancingNotes(true) - setEnhanceError(null) - try { - const resp = await window.electronAPI.meetingMode.generateDocument(request) - if (resp.success) { - setDocuments((prev) => [...prev, resp.data]) - setActiveTabKey(`doc-${resp.data.id}`) - } else { - setEnhanceError(resp.error.message) - } - } catch (err: unknown) { - setEnhanceError(err instanceof Error ? err.message : String(err)) - } finally { - setEnhancingNotes(false) - } - }, [enhancingNotes, detail.id, scratchpadNotes, templates]) - - // ── 새 문서 생성 다이얼로그 ── - const handleGenerate = useCallback( - async (templateId: string, customPrompt?: string, customTitle?: string) => { - setGenerating(true) - const resp = await window.electronAPI.meetingMode.generateDocument({ - sessionId: detail.id, - templateId, - customPrompt, - customTitle, - }) - setGenerating(false) - if (resp.success) { - setDocuments((prev) => [...prev, resp.data]) - setActiveTabKey(`doc-${resp.data.id}`) - setDialogOpen(false) - } + // ── 문서 생성 ── (요약 만들기 · 새 문서 다이얼로그 · 내 메모로 만들기 모두 main 의 generateDocument 한 번) + const onGenerated = useCallback( + (doc: MeetingDocument, origin: MeetingDetailTab) => { + addDocument(doc) + setPickedDocId(doc.id) + if (origin !== 'summary') setTab('documents') }, - [detail.id], + [addDocument], ) + const handleGenerate = useCallback( + async (templateId: string, customPrompt?: string, customTitle?: string): Promise => { + if (generating) return false + setGenerating(true) + try { + const resp = await window.electronAPI.meetingMode.generateDocument({ + sessionId: detail.id, + templateId, + customPrompt, + customTitle, + }) + if (!resp.success) { + notify({ message: t('meetings.summary.generateFailed', { message: resp.error.message }), severity: 'error' }) + return false + } + onGenerated(resp.data, dialogOriginRef.current) + return true + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.summary.generateFailed', { message }), severity: 'error' }) + return false + } finally { + setGenerating(false) + } + }, + [generating, detail.id, notify, onGenerated, t], + ) + + const handleCreateSummary = useCallback(() => { + dialogOriginRef.current = 'summary' + void handleGenerate(pickNotesEnhanceTemplateId(templates)) + }, [handleGenerate, templates]) + + const openDocumentDialog = useCallback( + (origin: MeetingDetailTab) => { + if (dialogOpen || generating) return + dialogOriginRef.current = origin + setDialogOpen(true) + }, + [dialogOpen, generating], + ) + + const handleDialogGenerate = useCallback( + async (templateId: string, customPrompt?: string, customTitle?: string) => { + if (await handleGenerate(templateId, customPrompt, customTitle)) setDialogOpen(false) + }, + [handleGenerate], + ) + + // ── 내 메모로 회의록 만들기 ── + // main 의 generateDocument 한 번으로 생성한다(전사·메모는 main 이 직접 읽는다). + const handleEnhanceNotes = useCallback( + async (notes: string) => { + if (enhancingNotes) return + const request = buildNotesEnhanceRequest({ + sessionId: detail.id, + notes, + templates, + title: t('meetings.notes.enhancedDocTitle'), + }) + if (!request) return + setEnhancingNotes(true) + try { + const resp = await window.electronAPI.meetingMode.generateDocument(request) + if (resp.success) { + onGenerated(resp.data, 'notes') + } else { + notify({ message: t('meetings.summary.generateFailed', { message: resp.error.message }), severity: 'error' }) + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.summary.generateFailed', { message }), severity: 'error' }) + } finally { + setEnhancingNotes(false) + } + }, + [enhancingNotes, detail.id, templates, notify, onGenerated, t], + ) + + const docTitle = (doc: MeetingDocument, index: number): string => + doc.title || t('meetings.doc.untitled', { n: index + 1 }) + + const openAsk = (): void => { + setAskMounted(true) + setAskOpen((prev) => !prev) + } + + // ── 렌더 ── + const titleText = detail.title || t('meeting.untitled') + const metaParts: string[] = [ + `${formatDayLabel(detail.startedAt, locale)} ${formatClockTime(detail.startedAt, locale)}`, + ] + if (detail.durationMs != null) metaParts.push(formatDurationLong(detail.durationMs, locale)) + const memoCount = detail.memos?.length ?? 0 + + const currentDoc = documents.find((d) => d.id === selectedDocId) + const currentDocIndex = currentDoc ? documents.indexOf(currentDoc) : -1 + return ( - - {/* ── 1. Header Toolbar ──────────────────────────────────────── */} + + {/* ── 1. 머리 ── */} + + } + sx={{ ml: -1.5, mb: 1 }} + > + {t('meetings.detail.back')} + + + {editingTitle ? ( + + setTitleDraft(e.target.value)} + onKeyDown={(e) => { + if (isImeComposingEvent(e)) return + if (e.key === 'Enter') void handleSaveTitle() + else if (e.key === 'Escape') cancelTitleEdit() + }} + slotProps={{ htmlInput: { 'aria-label': t('meetings.renameDialog.label') } }} + sx={{ flex: 1, '& .MuiInputBase-input': { ...typoSx('heading') } }} + autoFocus + /> + + + void handleSaveTitle()} + disabled={!titleDraft.trim()} + aria-label={t('common.save')} + > + + + + + + + + + + + ) : ( + + + {titleText} + + + { + setTitleDraft(detail.title ?? '') + setEditingTitle(true) + }} + aria-label={t('meeting.editTitle')} + sx={{ color: d3roPalette.text.tertiary, flexShrink: 0 }} + > + + + + + )} + + + {metaParts.join(' · ')} + {memoCount > 0 && ( + + · {t('meetings.memoCount', { count: memoCount })} + + )} + {shouldShowStatusBadge(detail.status) && ( + {t(meetingStatusLabelKey(detail.status))} + )} + + {detail.status === 'error' && detail.errorMessage && ( + + {detail.errorMessage} + + )} + + + {/* ── 2. 행동 줄 — 재사용 행동은 여기 한 곳 ── */} + + void copyText(summaryText, t('meetings.detail.summaryCopied'))} + disabled={!summaryText} + startIcon={} + > + {t('meetings.detail.copySummary')} + + void copyText(transcript.selectedText, t('meetings.detail.transcriptCopied'))} + disabled={!hasTranscript} + startIcon={} + > + {t('meetings.detail.copyTranscript')} + + + + setConfirmDelete(true)} + startIcon={} + > + {t('common.delete')} + + + + {/* ── 3. 탭 + 질문하기 ── */} - {/* Left: Back & Title */} - - - - - - {editingTitle ? ( - - 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 + setTab(v)} + aria-label={t('meetings.tabs.label')} + variant="scrollable" + scrollButtons={false} + sx={{ + minHeight: 40, + flex: 1, + minWidth: 0, + '& .MuiTab-root': { + ...typoSx('compact'), + fontWeight: 500, + minHeight: 40, + minWidth: 0, + px: 1.5, + textTransform: 'none', + color: d3roPalette.text.secondary, + '&.Mui-selected': { color: d3roPalette.text.primary }, + }, + }} + > + {MEETING_DETAIL_TABS.map((key) => { + const count = key === 'documents' ? documents.length : key === 'notes' ? memoCount : 0 + return ( + + {t(TAB_LABEL_KEYS[key])} + {count > 0 && ( + + {count} + + )} + + } /> - - - - - ) : ( - - - {detail.title || t('meeting.untitled')} - - - setEditingTitle(true)} sx={{ color: d3roPalette.text.inactive, p: 0.5 }}> - - - - - )} - - - {new Date(detail.startedAt || Date.now()).toLocaleDateString()} - - - - {/* Right: Viewport Mode Switcher (Split / Transcript / Doc) */} - - setViewMode(v as ViewMode)} - size="small" - /> - - { - if (!dialogOpen && !generating) setDialogOpen(true) - }} - disabled={dialogOpen || generating} - trailingIcon={} - > - {t('meeting.addDocument')} - - + ) + })} + + } + sx={askOpen ? { color: d3roPalette.text.primary, bgcolor: d3roPalette.surface.overlaySoft } : undefined} + > + {t('meetings.ask')} + - {/* ── 2. Dual-Canvas Workspace (Split View) ─────────────────── */} - - {/* Left Canvas: Interactive Transcript Timeline */} - {(viewMode === 'split' || viewMode === 'transcript') && ( - + {tab === 'summary' && ( + + void copyText(formatActionItemsForCopy(actionItems), t('meetings.summary.actionItemsCopied')) + } + canGenerate={hasTranscript} + generating={generating} + onCreateSummary={handleCreateSummary} + onChooseTemplate={() => openDocumentDialog('summary')} + onEditDocument={(docId) => { + setPickedDocId(docId) + setTab('documents') }} - > - - - - - SPEAKER TRANSCRIPT TIMELINE - - - - {transcript.segments.length} UTTERANCES - - - - - void transcript.polish()} - polishing={transcript.polishing} - onDiarize={() => void handleDiarize()} - diarizing={transcript.diarizing} - /> - - + /> )} - {/* Right Canvas: Structured AI Minutes & Granola Notepad */} - {(viewMode === 'split' || viewMode === 'doc') && ( - - {/* Right Sub-Tabs */} - - 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 }, - }, - }} + {tab === 'transcript' && ( + void transcript.polish()} + polishing={transcript.polishing} + onDiarize={() => void handleDiarize()} + diarizing={transcript.diarizing} + /> + )} + + {tab === 'documents' && + (documents.length === 0 ? ( + openDocumentDialog('documents')} + disabled={generating} + startIcon={} + > + {t('meetings.doc.new')} + + } + /> + ) : ( + + {documents.map((doc, idx) => ( - + setPickedDocId(doc.id)}> + {docTitle(doc, idx)} + ))} - - - - - - {/* Document Content View */} - - {activeTabKey.startsWith('doc-') ? ( - // AI Document Editor Tab - (() => { - const targetDocId = activeTabKey.replace('doc-', '') - const currentDoc = documents.find((d) => d.id === targetDocId) || documents[0] - return currentDoc ? ( - handleDocContentChange(currentDoc.id, c)} - onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)} - onDelete={() => handleDeleteDocument(currentDoc.id)} - /> - ) : ( - - 문서가 없습니다. 새 문서를 생성해주세요. - - ) - })() - ) : activeTabKey === 'notepad' ? ( - // Granola-style Notepad Tab - - - - - Granola-style Hybrid Notepad - - - 회의 중 메모를 자유롭게 적어보세요. AI가 전체 발화 내용과 결합하여 완벽한 회의록으로 증강해 드립니다. - - - - - {enhancingNotes ? '증강 중...' : '⚡ AI로 메모 증강하기'} - - - - setScratchpadNotes(e.target.value)} - placeholder="- 논의된 주요 이슈 요약 - 고객사 피드백 핵심 포인트 - 내가 해야 할 일 체크리스트..." - fullWidth - sx={{ - flex: 1, - '& .MuiOutlinedInput-root': { - fontFamily: d3roFontSans, - fontSize: '13px', - lineHeight: 1.7, - bgcolor: d3roPalette.bg.inset, - borderRadius: d3roRadius.inner, - p: 2, - }, - }} - /> - - ) : ( - // Interactive Action Items Checklist Tab - - - - Interactive Action Items Checklist - - { - const formatted = actionItems.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n') - navigator.clipboard.writeText(formatted) - }} - > - - Copy for Slack / Notion - - - - {actionItems.length === 0 ? ( - - 문서에서 추출된 액션 아이템이 없습니다. - - ) : ( - - {actionItems.map((item) => ( - - { - 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 }} - /> - - {item.text} - - - ))} - - )} - + openDocumentDialog('documents')} + disabled={dialogOpen || generating} + startIcon={} + > + {t('meetings.doc.new')} + + + {documents.length > 1 && ( + + {t('meetings.doc.primaryHint')} + )} + 1 ? 0 : 1.5 }}> + {currentDoc && ( + // 문서마다 key — 편집기 상태가 문서 사이에 섞이지 않고, 떠날 때 그 문서의 대기 저장을 바로 실행한다. + docs.editContent(currentDoc.id, c)} + onLeave={() => void docs.flush(currentDoc.id)} + onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)} + onDelete={() => handleDeleteDocument(currentDoc.id)} + /> + )} + - + ))} + + {tab === 'notes' && ( + void handleEnhanceNotes(notes)} + /> )} - {/* ── 3. Bottom Meeting Copilot Chat Panel ──────────────────── */} - + {/* ── 5. 질문하기 (접어 둔 회의 AI 채팅) ── */} + {askMounted && ( + + setAskOpen(false)} /> + + )} - {/* Add Document Modal */} setDialogOpen(false)} templates={templates} generating={generating} - onGenerate={handleGenerate} + onGenerate={handleDialogGenerate} /> - - - {errorMessage} - - + void handleDeleteMeeting()} + onClose={() => setConfirmDelete(false)} + /> ) } diff --git a/apps/desktop/src/renderer/components/meeting/MeetingNotesTab.tsx b/apps/desktop/src/renderer/components/meeting/MeetingNotesTab.tsx new file mode 100644 index 0000000..d4604b0 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/MeetingNotesTab.tsx @@ -0,0 +1,136 @@ +// src/renderer/components/meeting/MeetingNotesTab.tsx +// 회의 상세 "내 메모" 탭 — 녹음 중 남긴 회의 메모(시각 포함, 읽기 전용) + 자유 노트. +// 자유 노트는 입력마다 이 기기에 저장한다(meeting-notepad-storage). 예전 "Granola Notepad" 는 +// React 상태에만 있어 회의를 닫으면 적은 글이 사라졌다. + +import { useState } from 'react' +import { Box, TextField, Typography } from '@mui/material' +import { Sparkles } from 'lucide-react' +import { PhysicalButton } from '@d3ro/ui/components/ds' +import { d3roFontMono, d3roFontSans, d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { MeetingMemo } from '@d3ro/core/types' +import { defaultNotepadStorage, loadNotepad, saveNotepad } from './meeting-notepad-storage' +import { formatElapsed } from './meeting-format' + +interface MeetingNotesTabProps { + sessionId: string + recordedMemos: readonly MeetingMemo[] + enhancing: boolean + onEnhance: (notes: string) => void +} + +export function MeetingNotesTab({ + sessionId, + recordedMemos, + enhancing, + onEnhance, +}: MeetingNotesTabProps): React.ReactElement { + const { t } = useI18n() + const [storage] = useState(defaultNotepadStorage) + const [notes, setNotes] = useState(() => loadNotepad(storage, sessionId)) + const [saveFailed, setSaveFailed] = useState(false) + + const handleChange = (value: string): void => { + setNotes(value) + setSaveFailed(!saveNotepad(storage, sessionId, value)) + } + + return ( + + {recordedMemos.length > 0 && ( + + + {t('meetings.notes.recordedTitle')} + + + {recordedMemos.map((memo) => ( + + + {formatElapsed(memo.timestampMs)} + + {memo.content} + + ))} + + + )} + + + + + + {t('meetings.notes.padTitle')} + + + {saveFailed ? t('meetings.notes.saveFailed') : t('meetings.notes.padHint')} + + + + onEnhance(notes)} + disabled={!notes.trim() || enhancing} + startIcon={} + > + {enhancing ? t('meetings.notes.enhancing') : t('meetings.notes.enhance')} + + + {t('meetings.notes.enhanceHint')} + + + + + handleChange(e.target.value)} + placeholder={t('meetings.notes.placeholder')} + fullWidth + slotProps={{ htmlInput: { 'aria-label': t('meetings.notes.padTitle') } }} + sx={{ + '& .MuiOutlinedInput-root': { + fontFamily: d3roFontSans, + ...typoSx('body'), + lineHeight: 1.7, + bgcolor: d3roPalette.bg.input, + borderRadius: d3roRadius.inner, + }, + }} + /> + + + ) +} diff --git a/apps/desktop/src/renderer/components/meeting/MeetingSummaryTab.tsx b/apps/desktop/src/renderer/components/meeting/MeetingSummaryTab.tsx new file mode 100644 index 0000000..72f5fc5 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/MeetingSummaryTab.tsx @@ -0,0 +1,203 @@ +// src/renderer/components/meeting/MeetingSummaryTab.tsx +// 회의 상세 "요약" 탭 — 화면에서 가장 강한 덩어리. 할 일(체크) → 요약 본문 순서. +// 무엇을 보여 줄지는 meeting-summary-model 이 고른다. 요약이 없으면 만드는 행동 하나를 준다. + +import { Box, Checkbox, LinearProgress, Typography } from '@mui/material' +import { Copy, PencilLine, Sparkles } from 'lucide-react' +import { PhysicalButton } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import { EmptyStateCard } from '../shared' +import { MarkdownRenderer } from './MarkdownRenderer' +import { withoutChecklistLines, type ActionItem } from './meeting-documents-model' +import type { SummarySource } from './meeting-summary-model' + +interface MeetingSummaryTabProps { + source: SummarySource + /** 1순위 문서에서 파생한 할 일 */ + actionItems: readonly ActionItem[] + onToggleActionItem: (item: ActionItem, done: boolean) => void + onCopyActionItems: () => void + /** 전사가 있어 요약을 만들 수 있는가 */ + canGenerate: boolean + generating: boolean + onCreateSummary: () => void + onChooseTemplate: () => void + onEditDocument: (documentId: string) => void +} + +function SectionTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }): React.ReactElement { + return ( + + + {children} + + {action} + + ) +} + +export function MeetingSummaryTab({ + source, + actionItems, + onToggleActionItem, + onCopyActionItems, + canGenerate, + generating, + onCreateSummary, + onChooseTemplate, + onEditDocument, +}: MeetingSummaryTabProps): React.ReactElement { + const { t } = useI18n() + + if (source.kind === 'none') { + if (generating) { + return ( + + + + {t('meetings.summary.creating')} + + + ) + } + return ( + } + action={ + canGenerate ? ( + + + {t('meetings.summary.create')} + + + {t('meetings.summary.chooseTemplate')} + + + ) : undefined + } + /> + ) + } + + if (source.kind === 'minutes') { + const { minutes } = source + return ( + + {minutes.summary.trim() && ( + + {minutes.summary} + + )} + {minutes.decisions.length > 0 && ( + + {t('meetings.summary.decisions')} + + {minutes.decisions.map((d, i) => ( +
  • {d}
  • + ))} +
    +
    + )} + {minutes.actionItems.length > 0 && ( + + {t('meetings.summary.actionItems')} + + {minutes.actionItems.map((a, i) => ( +
  • + {a.task} + {(a.assignee || a.deadline) && ( + + {` · ${[a.assignee, a.deadline].filter(Boolean).join(' · ')}`} + + )} +
  • + ))} +
    +
    + )} +
    + ) + } + + if (source.kind === 'markdown') { + return + } + + // 1순위 문서 — 할 일은 체크 목록으로 위에, 본문에서는 같은 줄을 뺀다 + const doc = source.document + const body = actionItems.length > 0 ? withoutChecklistLines(doc.content) : doc.content + return ( + + + + {doc.title} + + onEditDocument(doc.id)} + startIcon={} + > + {t('meetings.summary.edit')} + + + + {actionItems.length > 0 && ( + + }> + {t('meetings.summary.copyActionItems')} + + } + > + {t('meetings.summary.actionItems')} + + {actionItems.filter((a) => !a.done).length}/{actionItems.length} + + + + {actionItems.map((item) => ( + + onToggleActionItem(item, e.target.checked)} + size="small" + slotProps={{ input: { 'aria-label': item.text } }} + sx={{ p: 0.5, mt: '-2px' }} + /> + + {item.text} + + + ))} + + + )} + + + + + + ) +} diff --git a/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx b/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx index 7dc7a80..71fc7bf 100644 --- a/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx +++ b/apps/desktop/src/renderer/components/meeting/TranscriptTab.tsx @@ -1,15 +1,15 @@ // src/renderer/components/meeting/TranscriptTab.tsx -// Phase 14.5: 전사 편집 탭 (상세 페이지용) +// Phase 14.5: 전사 편집 탭 (상세 페이지용) — 요약의 근거. +// 복사·내보내기는 상세 머리의 행동 줄 한 곳에 있다(예전 "전사 다운로드" 버튼은 거기로 옮겼다). -import { useCallback } from 'react' -import { Box, Switch, FormControlLabel, LinearProgress } from '@mui/material' +import { Box, Switch, FormControlLabel, LinearProgress, Typography } from '@mui/material' import { EditableSegment } from './EditableSegment' import { PhysicalButton } from '@d3ro/ui/components/ds' -import { PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' +import { d3roPalette, d3roFontMono, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' -import { Download, WandSparkles, Speech } from 'lucide-react' +import { WandSparkles, Speech } from 'lucide-react' import type { TranscriptSegmentView, TranscriptView } from './transcript-segments' +import { formatElapsed } from './meeting-format' // 표시 전용 컴포넌트: 보기 상태·저장·AI 다듬기·화자 구분은 useMeetingTranscript 가 가진다. // 보기 스위치는 onViewChange 만 부르고 어떤 저장도 하지 않는다. @@ -17,8 +17,6 @@ interface TranscriptTabProps { segments: TranscriptSegmentView[] view: TranscriptView onViewChange: (view: TranscriptView) => void - /** 현재 보기의 전사 텍스트 — 다운로드 내용. */ - selectedText: string /** false 면 세그먼트를 읽기 전용으로 보여 준다(원문 보기). */ editable: boolean memos: Array<{ id: string; timestampMs: number; content: string }> @@ -37,7 +35,6 @@ export function TranscriptTab({ segments, view, onViewChange, - selectedText, editable, memos, onEditSegment, @@ -49,16 +46,6 @@ export function TranscriptTab({ const { t } = useI18n() const showEdited = view === 'edited' - const handleDownloadTxt = useCallback(() => { - const blob = new Blob([selectedText], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'transcript.txt' - a.click() - URL.revokeObjectURL(url) - }, [selectedText]) - // 세그먼트 + 메모를 타임스탬프 순으로 인터리브 const timeline: TimelineItem[] = [ ...segments.map((s) => ({ @@ -78,84 +65,63 @@ export function TranscriptTab({ ].sort((a, b) => a.timestamp - b.timestamp) return ( - + {/* 상단 컨트롤 */} - + onViewChange(e.target.checked ? 'edited' : 'raw')} - sx={{ - '& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.main }, - '& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.dim }, - }} /> } label={ - + {showEdited ? t('meeting.editedText') : t('meeting.originalText')} - +
    } /> - + } > - {polishing ? t('meeting.polishing') : t('meeting.polish')} {onDiarize && ( } > - {diarizing ? t('meeting.diarizing') : t('meeting.diarize')} )} - - - {t('meeting.downloadTranscript')} - - {/* 폴리싱 진행 표시 */} - {polishing && ( - - )} + {/* 다듬기 진행 표시 */} + {polishing && } - {/* 타임라인 스크롤 영역 */} - + {/* 타임라인 */} + {timeline.length === 0 ? ( - - {t('meeting.noSessions')} - + + {t('meetings.transcript.empty')} + ) : ( timeline.map((item) => { if (item.kind === 'segment') { @@ -172,39 +138,35 @@ export function TranscriptTab({ /> ) } - // memo + // 회의 중 메모 — 전사 사이 그 시각에 끼워 보인다 return ( - {`[M ${String(Math.floor(item.timestamp / 60000)).padStart(2, '0')}:${String(Math.floor((item.timestamp % 60000) / 1000)).padStart(2, '0')}]`} + {formatElapsed(item.timestamp)} - + + {t('meetings.transcript.memo')} + + {item.content} diff --git a/apps/desktop/src/renderer/components/meeting/meeting-documents-model.ts b/apps/desktop/src/renderer/components/meeting/meeting-documents-model.ts new file mode 100644 index 0000000..1244ed6 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/meeting-documents-model.ts @@ -0,0 +1,314 @@ +// src/renderer/components/meeting/meeting-documents-model.ts +// 회의 상세 "문서 작업 공간"의 규칙을 모은 React 없는 모델. +// +// - 문서 본문(markdown)이 유일한 정본이다. 액션 아이템은 1순위 문서에서 파생하며, 체크는 +// 해당 소스 줄의 `[ ]`/`[x]` 를 바꿔 문서를 다시 저장하는 것으로 표현한다. +// - 편집은 상태에 즉시 반영하고, 저장은 문서별 디바운스 타이머가 뒤에서 한다. +// (문서 A 의 대기 중 저장이 문서 B 의 입력으로 취소되지 않는다.) +// - 같은 문서의 저장은 순서대로 한 번에 하나씩, 그 시점의 최신 본문으로 실행한다. +// - 저장·삭제 실패는 성공처럼 반영하지 않고 error 로 드러낸다. +// 영속화는 MeetingDocumentsPort 로 주입받아 IPC 없이도 검증할 수 있다. + +import type { IPCResult } from '@d3ro/core/errors' +import type { MeetingDocument } from '@d3ro/core/types' + +// ── 상세 탭 · 문서 선택 ───────────────────────────────────────────── + +/** 회의 상세의 탭 — 요약이 먼저, 전사는 근거, 문서·내 메모는 작업 공간. */ +export type MeetingDetailTab = 'summary' | 'transcript' | 'documents' | 'notes' + +export const MEETING_DETAIL_TABS: readonly MeetingDetailTab[] = ['summary', 'transcript', 'documents', 'notes'] + +/** + * 문서 탭에서 고른 문서 id 를 현재 문서 목록에 맞춘다. + * 고른 문서가 사라졌거나 아직 고르지 않았으면 첫 문서, 문서가 없으면 null. + */ +export function resolveSelectedDocumentId( + selectedId: string | null, + documents: readonly Pick[], +): string | null { + if (selectedId !== null && documents.some((d) => d.id === selectedId)) return selectedId + return documents[0]?.id ?? null +} + +// ── 액션 아이템(체크리스트) ───────────────────────────────────────── + +export interface ActionItem { + /** React key 용. 소스 줄 번호에서 만든다. */ + id: string + /** 문서 본문에서의 0 기반 줄 번호 — 체크 토글의 기준. */ + lineIndex: number + text: string + done: boolean +} + +// 1: 체크 표시 앞까지, 2: 표시, 3: 닫는 괄호와 공백, 4: 본문, 5: CRLF 의 \r +const CHECKLIST_LINE = /^(\s*[-*]\s*\[)([ xX])(\]\s*)(.+?)(\r?)$/ + +export function extractActionItems(content: string): ActionItem[] { + const items: ActionItem[] = [] + content.split('\n').forEach((line, lineIndex) => { + const match = CHECKLIST_LINE.exec(line) + if (!match) return + items.push({ + id: `todo-${lineIndex}`, + lineIndex, + done: match[2].toLowerCase() === 'x', + text: match[4].trim(), + }) + }) + return items +} + +/** 1순위 문서(첫 문서)의 액션 아이템. 문서가 없으면 빈 목록이다(이전 목록을 남기지 않는다). */ +export function selectPrimaryActionItems(documents: readonly Pick[]): ActionItem[] { + const primary = documents[0] + return primary ? extractActionItems(primary.content) : [] +} + +/** + * 체크리스트 줄을 뺀 본문 — 요약 탭은 할 일을 위쪽 체크 목록으로 따로 보여 주므로, 본문에서 같은 줄을 + * 두 번 보이지 않게 한다(표시 전용 — 저장되는 문서는 바꾸지 않는다). + */ +export function withoutChecklistLines(content: string): string { + return content + .split('\n') + .filter((line) => !CHECKLIST_LINE.test(line)) + .join('\n') +} + +/** + * 소스 줄 lineIndex 의 체크 상태를 done 으로 바꾼 새 markdown 을 돌려준다. + * 그 줄이 체크리스트가 아니거나 이미 같은 상태면 null. + * 체크 표시 한 글자만 바꾸고 들여쓰기·불릿·본문·줄바꿈은 그대로 둔다. + */ +export function toggleActionItem(content: string, lineIndex: number, done: boolean): string | null { + const lines = content.split('\n') + const line = lines[lineIndex] + if (line === undefined) return null + const match = CHECKLIST_LINE.exec(line) + if (!match) return null + if ((match[2].toLowerCase() === 'x') === done) return null + const markAt = match[1].length + lines[lineIndex] = `${line.slice(0, markAt)}${done ? 'x' : ' '}${line.slice(markAt + 1)}` + return lines.join('\n') +} + +// ── 문서 저장소 ──────────────────────────────────────────────────── + +export const DOCUMENT_AUTOSAVE_DELAY_MS = 500 + +export type DocumentWriteOutcome = { ok: true } | { ok: false; message: string } + +/** 문서 영속화 포트. 렌더러에서는 meetingMode.updateDocument/deleteDocument IPC 가 구현한다. */ +export interface MeetingDocumentsPort { + saveContent(documentId: string, content: string): Promise + remove(documentId: string): Promise +} + +type VoidIpc

    = (params: P) => Promise> + +function toOutcome(resp: IPCResult): DocumentWriteOutcome { + return resp.success ? { ok: true } : { ok: false, message: resp.error.message } +} + +/** meetingMode IPC 를 문서 영속화 포트로 감싼다. */ +export function meetingDocumentsPortFromApi(api: { + updateDocument: VoidIpc<{ documentId: string; content: string }> + deleteDocument: VoidIpc<{ documentId: string }> +}): MeetingDocumentsPort { + return { + saveContent: async (documentId, content) => toOutcome(await api.updateDocument({ documentId, content })), + remove: async (documentId) => toOutcome(await api.deleteDocument({ documentId })), + } +} + +export interface MeetingDocumentsState { + documents: readonly MeetingDocument[] + /** 마지막 저장·삭제 실패 메시지. */ + error: string | null +} + +export type DocumentCommandResult = + | { status: 'saved' } + | { status: 'skipped' } + | { status: 'failed'; message: string } + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +export class MeetingDocumentsStore { + private state: MeetingDocumentsState + private readonly listeners = new Set<() => void>() + private readonly timers = new Map>() + private readonly queues = new Map>() + /** 문서별 마지막으로 저장이 확정된 본문 — 같은 내용을 다시 쓰지 않는다. */ + private readonly persisted = new Map() + + constructor( + initial: readonly MeetingDocument[], + private readonly port: MeetingDocumentsPort, + private readonly autosaveDelayMs: number = DOCUMENT_AUTOSAVE_DELAY_MS, + ) { + this.state = { documents: [...initial], error: null } + for (const doc of initial) this.persisted.set(doc.id, doc.content) + } + + /** 불변 스냅샷 — 바뀔 때만 새 객체가 된다(useSyncExternalStore 호환). */ + readonly getState = (): MeetingDocumentsState => this.state + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** main 이 이미 저장한 새 문서(생성 결과)를 목록 끝에 붙인다. */ + readonly add = (doc: MeetingDocument): void => { + if (this.state.documents.some((d) => d.id === doc.id)) return + this.persisted.set(doc.id, doc.content) + this.setState({ ...this.state, documents: [...this.state.documents, doc] }) + } + + /** 본문을 즉시 바꾸고, 이 문서의 저장만 디바운스한다. */ + readonly editContent = (documentId: string, content: string): void => { + if (!this.replaceContent(documentId, content)) return + this.schedule(documentId) + } + + /** 대기 중인 저장을 바로 실행하고 이 문서의 저장이 모두 끝날 때까지 기다린다. */ + readonly flush = async (documentId: string): Promise => { + if (this.cancelTimer(documentId)) { + await this.enqueueSave(documentId) + return + } + await this.queues.get(documentId) + } + + readonly flushAll = async (): Promise => { + const ids = new Set([...this.timers.keys(), ...this.queues.keys()]) + await Promise.all([...ids].map((id) => this.flush(id))) + } + + /** + * 문서 lineIndex 줄의 체크 상태를 바꾸고 바로 저장한다(대기 중인 편집도 함께 저장된다). + * 저장에 실패하면 그 사이 다시 고치지 않은 경우에만 체크를 되돌린다. + */ + readonly toggleActionItem = async ( + documentId: string, + lineIndex: number, + done: boolean, + ): Promise => { + const doc = this.find(documentId) + if (!doc) return { status: 'skipped' } + const previous = doc.content + const next = toggleActionItem(previous, lineIndex, done) + if (next === null) return { status: 'skipped' } + + this.replaceContent(documentId, next) + this.cancelTimer(documentId) + const result = await this.enqueueSave(documentId) + if (result.status === 'failed' && this.find(documentId)?.content === next) { + this.replaceContent(documentId, previous) + } + return result + } + + /** 삭제가 확정된 뒤에만 목록에서 뺀다. 실패하면 문서를 그대로 두고 error 를 남긴다. */ + readonly remove = async (documentId: string): Promise => { + if (!this.find(documentId)) return { status: 'skipped' } + this.cancelTimer(documentId) + await this.queues.get(documentId) + + let outcome: DocumentWriteOutcome + try { + outcome = await this.port.remove(documentId) + } catch (err: unknown) { + outcome = { ok: false, message: errorMessage(err) } + } + if (!outcome.ok) { + this.setState({ ...this.state, error: outcome.message }) + return { status: 'failed', message: outcome.message } + } + this.persisted.delete(documentId) + this.setState({ + ...this.state, + documents: this.state.documents.filter((d) => d.id !== documentId), + }) + return { status: 'saved' } + } + + readonly clearError = (): void => { + if (this.state.error === null) return + this.setState({ ...this.state, error: null }) + } + + private find(documentId: string): MeetingDocument | undefined { + return this.state.documents.find((d) => d.id === documentId) + } + + private replaceContent(documentId: string, content: string): boolean { + const doc = this.find(documentId) + if (!doc || doc.content === content) return false + this.setState({ + ...this.state, + documents: this.state.documents.map((d) => (d.id === documentId ? { ...d, content } : d)), + }) + return true + } + + private schedule(documentId: string): void { + this.cancelTimer(documentId) + this.timers.set( + documentId, + setTimeout(() => { + this.timers.delete(documentId) + void this.enqueueSave(documentId) + }, this.autosaveDelayMs), + ) + } + + private cancelTimer(documentId: string): boolean { + const timer = this.timers.get(documentId) + if (timer === undefined) return false + clearTimeout(timer) + this.timers.delete(documentId) + return true + } + + /** 같은 문서의 저장을 직렬화한다. 실행 시점의 최신 본문을 쓴다. */ + private enqueueSave(documentId: string): Promise { + const previous = this.queues.get(documentId) ?? Promise.resolve({ status: 'skipped' }) + const next = previous.then(() => this.write(documentId)) + this.queues.set(documentId, next) + void next.then(() => { + if (this.queues.get(documentId) === next) this.queues.delete(documentId) + }) + return next + } + + private async write(documentId: string): Promise { + const doc = this.find(documentId) + if (!doc || this.persisted.get(documentId) === doc.content) return { status: 'skipped' } + const content = doc.content + let outcome: DocumentWriteOutcome + try { + outcome = await this.port.saveContent(documentId, content) + } catch (err: unknown) { + outcome = { ok: false, message: errorMessage(err) } + } + if (!outcome.ok) { + this.setState({ ...this.state, error: outcome.message }) + return { status: 'failed', message: outcome.message } + } + this.persisted.set(documentId, content) + return { status: 'saved' } + } + + private setState(next: MeetingDocumentsState): void { + this.state = next + for (const listener of this.listeners) listener() + } +} diff --git a/apps/desktop/src/renderer/components/meeting/meeting-format.ts b/apps/desktop/src/renderer/components/meeting/meeting-format.ts new file mode 100644 index 0000000..915d564 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/meeting-format.ts @@ -0,0 +1,50 @@ +// src/renderer/components/meeting/meeting-format.ts +// 회의 화면 공용 표기(React 없음) — 경과 시각, 회의 상태 라벨·배지. + +import type { TranslationKey } from '@d3ro/i18n' +import type { MeetingSessionSummary } from '@d3ro/core/types' + +/** 녹음 경과·전사 시각 — "mm:ss", 한 시간이 넘으면 "h:mm:ss". 라틴 숫자이므로 모노·tabular 로 그린다. */ +export function formatElapsed(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(totalSec / 3600) + const m = Math.floor((totalSec % 3600) / 60) + const s = totalSec % 60 + const mmss = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return h > 0 ? `${h}:${mmss}` : mmss +} + +export type MeetingStatus = MeetingSessionSummary['status'] + +export function meetingStatusLabelKey(status: MeetingStatus): TranslationKey { + switch (status) { + case 'recording': + return 'meetings.status.recording' + case 'processing': + return 'meetings.status.processing' + case 'error': + return 'meetings.status.error' + default: + return 'meetings.status.completed' + } +} + +/** + * 목록 줄에 배지를 달 상태인가. 대부분의 회의는 '완료'라 매 줄 같은 배지는 소음이다 — + * 녹음 중·저장 중·오류처럼 사용자가 알아야 하는 예외만 드러낸다. + */ +export function shouldShowStatusBadge(status: MeetingStatus): boolean { + return status !== 'completed' +} + +export function meetingStatusTone(status: MeetingStatus): 'default' | 'warning' | 'error' { + switch (status) { + case 'recording': + case 'error': + return 'error' + case 'processing': + return 'warning' + default: + return 'default' + } +} diff --git a/apps/desktop/src/renderer/components/meeting/meeting-notepad-storage.ts b/apps/desktop/src/renderer/components/meeting/meeting-notepad-storage.ts new file mode 100644 index 0000000..c136149 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/meeting-notepad-storage.ts @@ -0,0 +1,57 @@ +// src/renderer/components/meeting/meeting-notepad-storage.ts +// 회의 상세 "내 메모"(자유 노트)의 보관소. +// +// 예전 노트패드는 React 상태에만 있어서 회의를 닫거나 다른 화면에 다녀오면 적은 글이 사라졌다. +// 회의 메모(meeting_memos)의 addMemo IPC 는 녹음 중에만 받는다(MeetingModeService.addMemo 가 녹음 중이 +// 아니면 MeetingNotRecording 을 던진다) — 끝난 회의의 노트를 받아 줄 백엔드 자리가 없다. +// 그래서 백엔드를 바꾸지 않는 최소 조치로 이 기기의 localStorage 에 회의 id 별로 저장한다. +// 한계: 다른 기기와 동기화되지 않는다(화면에 그렇게 적는다). 동기화는 백엔드 필드가 생기면 옮긴다. + +const KEY_PREFIX = 'd3ro.meeting.notepad.' + +/** localStorage 의 필요한 부분만 — 테스트에서 가짜를 넣을 수 있게. */ +export type NotepadStorage = Pick + +export function notepadStorageKey(sessionId: string): string { + return `${KEY_PREFIX}${sessionId}` +} + +/** 브라우저 저장소. 접근 자체가 막힌 환경이면 null. */ +export function defaultNotepadStorage(): NotepadStorage | null { + try { + return typeof window !== 'undefined' ? window.localStorage : null + } catch { + return null + } +} + +export function loadNotepad(storage: NotepadStorage | null, sessionId: string): string { + if (!storage) return '' + try { + return storage.getItem(notepadStorageKey(sessionId)) ?? '' + } catch { + return '' + } +} + +/** 저장한다. 비었으면 키를 지운다. 저장에 실패하면 false(용량 초과 등). */ +export function saveNotepad(storage: NotepadStorage | null, sessionId: string, text: string): boolean { + if (!storage) return false + try { + if (text.trim() === '') storage.removeItem(notepadStorageKey(sessionId)) + else storage.setItem(notepadStorageKey(sessionId), text) + return true + } catch { + return false + } +} + +/** 회의를 지우면 그 회의의 노트도 지운다. */ +export function clearNotepad(storage: NotepadStorage | null, sessionId: string): void { + if (!storage) return + try { + storage.removeItem(notepadStorageKey(sessionId)) + } catch { + /* 지우지 못해도 다른 회의에 영향은 없다 */ + } +} diff --git a/apps/desktop/src/renderer/components/meeting/meeting-summary-model.ts b/apps/desktop/src/renderer/components/meeting/meeting-summary-model.ts new file mode 100644 index 0000000..f8bdf1e --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/meeting-summary-model.ts @@ -0,0 +1,75 @@ +// src/renderer/components/meeting/meeting-summary-model.ts +// 회의 상세 "요약" 탭이 무엇을 보여 줄지 고르는 규칙(React 없음). +// +// 이 기기의 회의 후처리는 요약을 자동으로 만들지 않는다 — 요약은 사용자가 만든 첫 문서(1순위 문서)다. +// 다른 기기(모바일 등)에서 동기화된 회의에는 minutesMarkdown / minutes 가 있을 수 있어 차례로 쓴다. +// 1순위 문서 → minutesMarkdown → minutes(구조) → 없음. + +import type { MeetingDocument, MeetingMinutes } from '@d3ro/core/types' +import type { ActionItem } from './meeting-documents-model' + +export type SummarySource = + | { kind: 'document'; document: MeetingDocument } + | { kind: 'markdown'; markdown: string } + | { kind: 'minutes'; minutes: MeetingMinutes } + | { kind: 'none' } + +export function selectSummarySource( + documents: readonly MeetingDocument[], + minutesMarkdown: string | null, + minutes: MeetingMinutes | null, +): SummarySource { + const primary = documents[0] + if (primary) return { kind: 'document', document: primary } + if (minutesMarkdown && minutesMarkdown.trim()) return { kind: 'markdown', markdown: minutesMarkdown } + if (minutes && (minutes.summary.trim() || minutes.decisions.length > 0 || minutes.actionItems.length > 0)) { + return { kind: 'minutes', minutes } + } + return { kind: 'none' } +} + +/** 구조화된 회의록(minutes)을 붙여 넣기 좋은 Markdown 으로. 소제목은 호출 쪽이 번역해 넘긴다. */ +export function minutesToMarkdown( + minutes: MeetingMinutes, + labels: { decisions: string; actionItems: string }, +): string { + const parts: string[] = [] + if (minutes.summary.trim()) parts.push(minutes.summary.trim()) + if (minutes.decisions.length > 0) { + parts.push([`## ${labels.decisions}`, ...minutes.decisions.map((d) => `- ${d}`)].join('\n')) + } + if (minutes.actionItems.length > 0) { + parts.push( + [ + `## ${labels.actionItems}`, + ...minutes.actionItems.map((a) => { + const meta = [a.assignee, a.deadline].filter((v): v is string => !!v && v.trim() !== '') + return `- [ ] ${a.task}${meta.length > 0 ? ` (${meta.join(', ')})` : ''}` + }), + ].join('\n'), + ) + } + return parts.join('\n\n') +} + +/** "요약 복사"가 클립보드에 넣을 글. 요약이 없으면 빈 문자열. */ +export function summaryCopyText( + source: SummarySource, + labels: { decisions: string; actionItems: string }, +): string { + switch (source.kind) { + case 'document': + return source.document.content.trim() + case 'markdown': + return source.markdown.trim() + case 'minutes': + return minutesToMarkdown(source.minutes, labels) + default: + return '' + } +} + +/** 할 일을 Slack·Notion 에 붙여 넣기 좋은 체크리스트로. */ +export function formatActionItemsForCopy(items: readonly Pick[]): string { + return items.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n') +} diff --git a/apps/desktop/src/renderer/components/meeting/useMeetingDocuments.ts b/apps/desktop/src/renderer/components/meeting/useMeetingDocuments.ts new file mode 100644 index 0000000..13cad9c --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/useMeetingDocuments.ts @@ -0,0 +1,51 @@ +// src/renderer/components/meeting/useMeetingDocuments.ts +// 회의 상세 문서 작업 공간(문서 목록·자동저장·삭제·액션 아이템 토글)을 잇는 얇은 React 훅. +// 규칙은 MeetingDocumentsStore(React 없음)에 있고, 여기서는 IPC 포트 연결과 수명 관리만 한다. + +import { useEffect, useState, useSyncExternalStore } from 'react' +import type { MeetingDocument } from '@d3ro/core/types' +import { + MeetingDocumentsStore, + meetingDocumentsPortFromApi, + type DocumentCommandResult, +} from './meeting-documents-model' + +export interface UseMeetingDocumentsResult { + documents: readonly MeetingDocument[] + /** 마지막 저장·삭제 실패 메시지(main 이 준 그대로). */ + error: string | null + clearError: () => void + /** 생성된 문서를 붙인다. */ + add: (doc: MeetingDocument) => void + /** 본문을 즉시 바꾸고 뒤에서 디바운스 저장한다. */ + editContent: (documentId: string, content: string) => void + /** 대기 중인 저장을 바로 실행한다(탭을 떠날 때). */ + flush: (documentId: string) => Promise + toggleActionItem: (documentId: string, lineIndex: number, done: boolean) => Promise + remove: (documentId: string) => Promise +} + +export function useMeetingDocuments(initialDocuments: readonly MeetingDocument[] | null | undefined): UseMeetingDocumentsResult { + const [store] = useState( + () => + new MeetingDocumentsStore( + (initialDocuments ?? []).filter(Boolean), + meetingDocumentsPortFromApi(window.electronAPI.meetingMode), + ), + ) + const state = useSyncExternalStore(store.subscribe, store.getState) + + // 화면을 떠나도 대기 중인 자동저장은 버리지 않는다. + useEffect(() => () => void store.flushAll(), [store]) + + return { + documents: state.documents, + error: state.error, + clearError: store.clearError, + add: store.add, + editContent: store.editContent, + flush: store.flush, + toggleActionItem: store.toggleActionItem, + remove: store.remove, + } +} diff --git a/apps/desktop/src/renderer/components/onboarding-dismiss.ts b/apps/desktop/src/renderer/components/onboarding-dismiss.ts new file mode 100644 index 0000000..f56cf18 --- /dev/null +++ b/apps/desktop/src/renderer/components/onboarding-dismiss.ts @@ -0,0 +1,11 @@ +// src/renderer/components/onboarding-dismiss.ts +// 온보딩 모달을 닫을 수 있는지 — 순수 정책. +// 첫 실행(온보딩 미완료)만 끝까지 진행을 강제한다. 이미 온보딩을 마친 사용자에게 모달이 다시 열렸다면 +// (예: 다른 경로가 여는 경우) Esc·배경 클릭·닫기 버튼으로 언제든 빠져나올 수 있어야 한다 — +// 예전에는 onClose 가 no-op 이라 Ollama 도 온라인 계정도 없는 사용자가 메인 창 전체에 갇혔다. + +export type OnboardingPhase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed' + +export function canDismissOnboarding(phase: OnboardingPhase, onboardingCompleted: boolean): boolean { + return phase === 'success' || onboardingCompleted +} diff --git a/apps/desktop/src/renderer/components/settings/SettingsRows.tsx b/apps/desktop/src/renderer/components/settings/SettingsRows.tsx new file mode 100644 index 0000000..8bff0d5 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/SettingsRows.tsx @@ -0,0 +1,95 @@ +// src/renderer/components/settings/SettingsRows.tsx +// 설정 본문 문법(v4, 2026-09-28): 한 열. 구획 = 소제목(h3, heading 토큰) + 1px 선. +// 행 = 왼쪽 라벨+설명(tertiary), 오른쪽 컨트롤. 카드 속 카드·글로우·모노 대문자 라벨을 쓰지 않는다. + +import { useId } from 'react' +import { Box, Typography } from '@mui/material' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' + +interface SettingsSectionProps { + title: string + /** 소제목 아래 한 줄 설명 */ + description?: React.ReactNode + /** 소제목 오른쪽 보조 컨트롤(전체 on/off 등) */ + action?: React.ReactNode + children: React.ReactNode +} + +/** 설정 구획 — 첫 구획은 위 선이 없다. */ +export function SettingsSection({ title, description, action, children }: SettingsSectionProps): React.ReactElement { + const headingId = useId() + return ( + + + + {title} + + {action && {action}} + + {description && ( + + {description} + + )} + {children} + + ) +} + +interface SettingRowProps { + label: React.ReactNode + description?: React.ReactNode + /** 오른쪽 컨트롤. 라벨과 연결하려면 labelId 를 컨트롤의 aria-labelledby(Select 는 labelId)로 넘긴다. */ + control?: React.ReactNode + /** 라벨 요소의 id — 지정하지 않으면 자동 생성 */ + labelId?: string + /** 행 아래 전체 폭으로 붙는 부가 내용(진행 바·오류 등) */ + children?: React.ReactNode +} + +/** 설정 행 — 좁은 창에서는 컨트롤이 라벨 아래로 내려간다. */ +export function SettingRow({ label, description, control, labelId, children }: SettingRowProps): React.ReactElement { + const autoId = useId() + const id = labelId ?? autoId + return ( + + + + + {label} + + {description && ( + + {description} + + )} + + {control && ( + {control} + )} + + {children && {children}} + + ) +} + +/** 행 안의 보조 문장(피드백·상태) — small 토큰, 색은 호출자가 status 로 고른다. */ +export function SettingNote({ children, color }: { children: React.ReactNode; color?: string }): React.ReactElement { + return ( + + {children} + + ) +} + +/** 행 안 Select·TextField 의 공통 폭 */ +export const SETTING_CONTROL_WIDTH = 240 diff --git a/apps/desktop/src/renderer/components/shared/ConfirmDialog.tsx b/apps/desktop/src/renderer/components/shared/ConfirmDialog.tsx new file mode 100644 index 0000000..fbbd110 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ConfirmDialog.tsx @@ -0,0 +1,61 @@ +// src/renderer/components/shared/ConfirmDialog.tsx +// 공유: 되돌릴 수 없는 행동의 확인. 되돌릴 수 있는 삭제(한 건 삭제 등)는 확인 대신 알림의 "되돌리기"를 쓴다(useNotify). +// 제목은 질문, 본문은 무엇이 사라지는지, 확인 버튼은 동사("삭제") — "확인/예" 금지. + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' + +export interface ConfirmDialogProps { + open: boolean + title: string + /** 무엇이 사라지거나 바뀌는지 */ + body?: React.ReactNode + /** 동사 레이블 (예: "삭제") */ + confirmLabel: string + destructive?: boolean + busy?: boolean + onConfirm: () => void + onClose: () => void +} + +export function ConfirmDialog({ + open, + title, + body, + confirmLabel, + destructive = false, + busy = false, + onConfirm, + onClose, +}: ConfirmDialogProps): React.ReactElement { + const { t } = useI18n() + return ( +

    + + {title} + + {body && ( + + + {body} + + + )} + + + + + + ) +} diff --git a/apps/desktop/src/renderer/components/shared/EmptyStateCard.tsx b/apps/desktop/src/renderer/components/shared/EmptyStateCard.tsx index da3a33f..1b91409 100644 --- a/apps/desktop/src/renderer/components/shared/EmptyStateCard.tsx +++ b/apps/desktop/src/renderer/components/shared/EmptyStateCard.tsx @@ -1,103 +1,51 @@ // src/renderer/components/shared/EmptyStateCard.tsx -// High-End Agency Composed Empty State with Vector Glow & Action Support +// 공유: 빈 상태 — 무엇이 없고, 무엇을 하면 생기는지. 글로우·캡슐 없이 선 하나 안에. +// 빈 상태 문구가 행동을 말하면(예: "녹음을 시작하세요") 그 행동을 action 으로 반드시 함께 둔다. 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, d3roTypo } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' 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, - description, - icon, - action, -}: EmptyStateCardProps): React.ReactElement { - // If message contains newline, split into title & description +export function EmptyStateCard({ message, description, icon, action }: EmptyStateCardProps): React.ReactElement { const lines = message.split('\n') const title = lines[0] const sub = description || (lines.length > 1 ? lines.slice(1).join(' ') : null) return ( -
    - - - {/* Glowing Icon Capsule */} - - {icon || } - - - {/* Title */} - - {title} - - - {/* Description */} - {sub && ( - - {sub} - - )} - - {/* Action CTA */} - {action && {action}} - - -
    + + + {icon || } + + {title} + {sub && ( + + {sub} + + )} + {action && {action}} + ) } diff --git a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx index 484905a..5dfd909 100644 --- a/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx +++ b/apps/desktop/src/renderer/components/shared/HistoryEntryCard.tsx @@ -1,21 +1,25 @@ // src/renderer/components/shared/HistoryEntryCard.tsx -// High-End Agency Dictation History Card with Audio Telemetry & Instant Actions +// 기록 한 줄 — 접힌 상태는 시간 · 모드 · 제목(본문 첫 줄) · 즐겨찾기만 보이는 목록 한 줄이고, +// 누르면(Enter/Space 포함) 그 자리에서 펼쳐져 요약 · 본문(다듬은 글 먼저, 원문은 접힌 보조) · 태그 · 행동 줄을 보인다. +// 재사용 행동(복사 · 원문 복사 · 녹음 듣기 · 즐겨찾기 · 태그 · 삭제)은 펼친 영역의 한 줄에만 둔다. +// 2026-09-28: 2열 카드(글래스·LED·배지 무더기·떠오르는 hover)를 목록 한 줄로 바꿨다. -import React, { useState, useEffect, useCallback, useRef } from 'react' -import { Box, IconButton, Tooltip, Chip, Collapse } from '@mui/material' -import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2, Star, Play, Square } from 'lucide-react' -import { MetalCard, Led, TactileBadge } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import React, { useCallback, useEffect, useId, useRef, useState } from 'react' +import { Box, ButtonBase, Chip, Collapse, IconButton, InputBase, Tooltip, Typography } from '@mui/material' +import { Check, ChevronDown, Copy, Play, Square, Star, Tag, Trash2, X } from 'lucide-react' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { d3roPalette, d3roFocusRing, d3roRadius, d3roTransition, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import type { HistoryEntry, MemoTag } from '@d3ro/core/types' import { formatDuration } from '../../utils/formatters' import { isImeComposingEvent } from '../../utils/keyboard' -import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types' +import { recordModeLabelKey } from '../../utils/display-labels' +import { recordOriginalText, recordPreview, recordPrimaryText } from '../../pages/history/records-view' import { HistoryAudioPlayer, addEntryTag, loadEntryTags, removeEntryTag, - summaryDisplayText, type HistoryAudioPort, type HistoryAudioState, } from './history-entry-card-actions' @@ -38,69 +42,238 @@ interface TagFailure { tag: string } +type CopiedKind = 'primary' | 'original' | null + +/** 줄 머리의 시간 열 너비 — 펼친 영역을 제목 열에 맞추는 데도 쓴다 */ +const TIME_COL = 64 + export interface HistoryEntryCardProps { entry: HistoryEntry - onCopy?: (text: string) => void - onDelete?: (id: string) => void + /** 줄 머리에 보일 시간 (날짜 묶음 안이므로 시:분) */ + timeLabel: string + expanded: boolean + onToggleExpanded: (id: string) => void + /** 클립보드에 쓴다. 성공 여부를 돌려준다 */ + onCopy: (text: string) => Promise + onDelete: (entry: HistoryEntry) => void /** 즐겨찾기 토글 — 모바일·웹과 공유되는 값 */ - onToggleFavorite?: (entry: HistoryEntry) => void - showTags?: boolean + onToggleFavorite: (entry: HistoryEntry) => void + /** 태그를 누르면 그 태그로 거른다 */ onTagClick?: (tag: string) => void + /** 지금 걸린 태그 필터 — 같은 태그 칩을 선택 상태로 보인다 */ + activeTag?: string | null + /** 태그를 붙이거나 뗐을 때 (화면의 태그 목록 갱신) */ + onTagsChanged?: () => void } export function HistoryEntryCard({ entry, + timeLabel, + expanded, + onToggleExpanded, onCopy, onDelete, onToggleFavorite, - showTags = false, onTagClick, + activeTag = null, + onTagsChanged, }: HistoryEntryCardProps): React.ReactElement { - const { t, formatTime } = useI18n() - const displayText = entry.polishedText || entry.originalText - const isPolished = Boolean(entry.polishedText) + const { t } = useI18n() + const panelId = useId() + const preview = recordPreview(entry) + const primaryText = recordPrimaryText(entry) + const originalText = recordOriginalText(entry) + const summaryText = entry.summaryText?.trim() || null + + return ( + + onToggleExpanded(entry.id)} + sx={{ + width: '100%', + display: 'flex', + alignItems: 'baseline', + justifyContent: 'flex-start', + gap: 2, + px: 1, + py: 1.25, + textAlign: 'left', + borderRadius: d3roRadius.small, + transition: d3roTransition('background-color'), + bgcolor: expanded ? d3roPalette.surface.overlaySoft : 'transparent', + '&:hover': { bgcolor: d3roPalette.surface.overlaySoft }, + '&:focus-visible': d3roFocusRing, + }} + > + + {timeLabel} + + {entry.mode !== 'dictation' && ( + + {t(recordModeLabelKey(entry.mode))} + + )} + {entry.status !== 'completed' && ( + + {entry.status === 'error' ? t('records.status.error') : t('records.status.cancelled')} + + )} + + + {preview.title} + + {preview.snippet && ( + + {preview.snippet} + + )} + + {entry.isFavorite && ( + + + + )} + + + + + + + + + + ) +} + +interface RecordDetailProps { + id: string + entry: HistoryEntry + label: string + primaryText: string + originalText: string | null + summaryText: string | null + onCopy: (text: string) => Promise + onDelete: (entry: HistoryEntry) => void + onToggleFavorite: (entry: HistoryEntry) => void + onTagClick?: (tag: string) => void + activeTag: string | null + onTagsChanged?: () => void +} + +function SectionLabel({ children }: { children: React.ReactNode }): React.ReactElement { + return ( + + {children} + + ) +} + +const iconButtonSx = { + p: 0.75, + color: d3roPalette.text.secondary, + borderRadius: d3roRadius.small, + '&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.surface.overlaySoft }, + '&:focus-visible': d3roFocusRing, +} as const + +/** 펼친 영역 — 펼칠 때만 마운트되므로 태그·오디오도 이때만 불러온다 */ +function RecordDetail({ + id, + entry, + label, + primaryText, + originalText, + summaryText, + onCopy, + onDelete, + onToggleFavorite, + onTagClick, + activeTag, + onTagsChanged, +}: RecordDetailProps): React.ReactElement { + const { t } = useI18n() const [tags, setTags] = useState([]) const [tagInput, setTagInput] = useState('') const [showTagInput, setShowTagInput] = useState(false) - const [summaryExpanded, setSummaryExpanded] = useState(false) - const [summary, setSummary] = useState(null) - const [summaryLoading, setSummaryLoading] = useState(false) - const [copied, setCopied] = useState(false) - const hasSummary = !!entry.summaryText - const [audioState, setAudioState] = useState('idle') const [tagFailure, setTagFailure] = useState(null) + const [showOriginal, setShowOriginal] = useState(false) + const [copied, setCopied] = useState(null) + const [audioState, setAudioState] = useState('idle') const playerRef = useRef(null) + const copiedTimerRef = useRef | null>(null) const getPlayer = useCallback((): HistoryAudioPlayer => { if (!playerRef.current) playerRef.current = new HistoryAudioPlayer(browserHistoryAudioPort, setAudioState) return playerRef.current }, []) - // 언마운트되면 받아 오는 중인 요청까지 끊는다. StrictMode 재마운트를 위해 참조도 비운다. + // 접히면(언마운트) 받아 오는 중인 요청까지 끊는다. StrictMode 재마운트를 위해 참조도 비운다. useEffect( () => () => { playerRef.current?.dispose() playerRef.current = null + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current) }, [], ) - // 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다. - const handleToggleAudio = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - const player = getPlayer() - if (audioState === 'playing') { - player.stop() - return - } - void player.play(entry.id) - }, - [audioState, entry.id, getPlayer], - ) - useEffect(() => { - if (!showTags) return let active = true void loadEntryTags(window.electronAPI.memo, entry.id).then((loaded) => { if (active && loaded) setTags(loaded) @@ -108,14 +281,29 @@ export function HistoryEntryCard({ return () => { active = false } - }, [entry.id, showTags]) + }, [entry.id]) - const handleCopyClick = (e: React.MouseEvent) => { - e.stopPropagation() - onCopy?.(displayText) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - } + // 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다. + const handleToggleAudio = useCallback(() => { + const player = getPlayer() + if (audioState === 'playing') { + player.stop() + return + } + void player.play(entry.id) + }, [audioState, entry.id, getPlayer]) + + const handleCopy = useCallback( + async (kind: Exclude) => { + const text = kind === 'original' && originalText !== null ? originalText : primaryText + const ok = await onCopy(text) + if (!ok) return + setCopied(kind) + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => setCopied(null), 1500) + }, + [onCopy, originalText, primaryText], + ) const handleAddTag = useCallback(async () => { const outcome = await addEntryTag(window.electronAPI.memo, entry.id, tagInput) @@ -125,10 +313,11 @@ export function HistoryEntryCard({ return } setTagFailure(null) - setTags((prev) => [...prev.filter((t) => t.tag !== outcome.tag.tag), outcome.tag]) + setTags((prev) => [...prev.filter((tg) => tg.tag !== outcome.tag.tag), outcome.tag]) setTagInput('') setShowTagInput(false) - }, [entry.id, tagInput]) + onTagsChanged?.() + }, [entry.id, tagInput, onTagsChanged]) const handleRemoveTag = useCallback( async (tag: string) => { @@ -138,9 +327,10 @@ export function HistoryEntryCard({ return } setTagFailure(null) - setTags((prev) => prev.filter((t) => t.tag !== tag)) + setTags((prev) => prev.filter((tg) => tg.tag !== tag)) + onTagsChanged?.() }, - [entry.id], + [entry.id, onTagsChanged], ) const handleRetryTag = useCallback(() => { @@ -158,357 +348,245 @@ export function HistoryEntryCard({ if (isImeComposingEvent(e)) return if (e.key === 'Enter') { e.preventDefault() - handleAddTag() + void handleAddTag() } if (e.key === 'Escape') { + e.stopPropagation() setShowTagInput(false) setTagInput('') + setTagFailure((prev) => (prev?.kind === 'add' ? null : prev)) } }, [handleAddTag], ) - 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], - ) + const labelled = Boolean(summaryText || originalText) + const meta = [ + entry.duration > 0 ? formatDuration(entry.duration) : null, + entry.focusedAppName, + entry.detectedLanguage ? entry.detectedLanguage.toUpperCase() : null, + ].filter((v): v is string => Boolean(v)) + + const audioLabel = + audioState === 'playing' + ? t('history.stopAudio') + : audioState === 'unavailable' + ? t('history.audioUnavailable') + : t('history.playAudio') + const favoriteLabel = entry.isFavorite ? t('history.unfavorite') : t('history.favorite') + const copyLabel = copied === 'primary' ? t('common.copied') : t('common.copy') + const copyOriginalLabel = copied === 'original' ? t('common.copied') : t('records.action.copyOriginal') return ( - - - {/* Status Indicator Bead */} - - + {summaryText && ( + + {t('meetingSummary.summary')} + + {summaryText} + + )} - - {/* Header Row: Title + Timestamp + Action Icons */} - - - - {entry.title ?? displayText.slice(0, 50)} - - {isPolished && ( - - - - - - )} - + + {labelled && {originalText ? t('records.section.polished') : t('records.section.original')}} + + {primaryText} + + - {/* Quick Action Buttons */} - - {entry.duration > 0 && ( - - - void handleToggleAudio(e)} - sx={{ - p: 0.6, - color: - audioState === 'playing' - ? d3roPalette.accent.main - : audioState === 'unavailable' - ? d3roPalette.tag.red - : d3roPalette.text.inactive, - bgcolor: audioState === 'playing' ? d3roPalette.accent.dim : 'transparent', - '&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised }, - }} - > - {audioState === 'playing' ? : } - - - - )} - {onToggleFavorite && ( - - { - e.stopPropagation() - onToggleFavorite(entry) - }} - sx={{ - p: 0.6, - color: entry.isFavorite ? d3roPalette.tag.orange : d3roPalette.text.inactive, - bgcolor: entry.isFavorite ? d3roPalette.tag.orangeBg : 'transparent', - '&:hover': { color: d3roPalette.tag.orange, bgcolor: d3roPalette.tag.orangeBg }, - }} - > - - - - )} - - - {copied ? : } - - - - {onDelete && ( - - { - e.stopPropagation() - onDelete(entry.id) - }} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - '&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg }, - }} - > - - - - )} - - - - {/* Transcript Content Preview */} - + setShowOriginal((v) => !v)} sx={{ - fontFamily: d3roFontSans, - fontSize: '13px', + ...typoSx('small'), color: d3roPalette.text.secondary, - lineHeight: 1.55, - overflow: 'hidden', - textOverflow: 'ellipsis', - display: '-webkit-box', - WebkitLineClamp: 2, - WebkitBoxOrient: 'vertical', - mb: 1.5, + gap: 0.5, + px: 0.5, + ml: -0.5, + borderRadius: d3roRadius.xs, + '&:hover': { color: d3roPalette.text.primary }, + '&:focus-visible': d3roFocusRing, }} > - {displayText} - - - {/* Metadata Telemetry Badges */} - - - {formatTime(entry.createdAt)} - - - - - {formatDuration(entry.duration)} - - - {entry.detectedLanguage && ( - - {entry.detectedLanguage.toUpperCase()} - - )} - - {hasSummary && ( - - - {t('meetingSummary.title')} - - - )} - - - {/* Tags Row */} - {showTags && ( - - {tags.map((tag) => ( - onTagClick?.(tag.tag)} - onDelete={() => handleRemoveTag(tag.tag)} - deleteIcon={} - sx={{ - height: 20, - fontFamily: d3roFontMono, - fontSize: '10.5px', - bgcolor: d3roPalette.tag.purpleBg, - color: d3roPalette.tag.purple, - borderRadius: d3roRadius.pill, - '& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 11 }, - '&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.text.inverse }, - }} - /> - ))} - {showTagInput ? ( - ) => { - setTagInput(e.target.value) - if (tagFailure?.kind === 'add') setTagFailure(null) - }} - onKeyDown={handleTagKeyDown} - onBlur={() => { - if (!tagInput.trim()) setShowTagInput(false) - }} - autoFocus - placeholder={t('memo.tagPlaceholder')} - aria-invalid={tagFailure?.kind === 'add'} - sx={{ - border: `1px solid ${tagFailure?.kind === 'add' ? d3roPalette.tag.red : d3roPalette.accent.main}`, - bgcolor: d3roPalette.bg.input, - color: d3roPalette.text.primary, - fontFamily: d3roFontMono, - fontSize: '11px', - px: 1, - py: 0.25, - borderRadius: d3roRadius.pill, - outline: 'none', - width: 90, - }} - /> - ) : ( - 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 }, - }} - > - - - )} - {tagFailure && ( - - {`#${tagFailure.tag} · ${t('common.retry')}`} - - )} - - )} - - {/* Expandable Meeting Summary Box */} - - + + + - {summaryLoading ? ( - - {t('common.loading')}... - - ) : summary ? ( - - - {summaryDisplayText(summary) || entry.summaryText || t('meetingSummary.noSummary')} - - - ) : ( - - {entry.summaryText || t('meetingSummary.noSummary')} - - )} - + {originalText} +
    + )} + + {(tags.length > 0 || showTagInput || tagFailure) && ( + + {tags.map((tag) => { + const selected = activeTag === tag.tag + return ( + onTagClick(tag.tag) : undefined} + onDelete={() => void handleRemoveTag(tag.tag)} + deleteIcon={ + + + + } + sx={{ + ...typoSx('small'), + height: 24, + color: selected ? d3roPalette.accent.light : d3roPalette.text.secondary, + borderColor: selected ? d3roPalette.accent.main : d3roPalette.border.default, + '& .MuiChip-deleteIcon': { color: d3roPalette.text.tertiary, mr: 0.5 }, + '& .MuiChip-deleteIcon:hover': { color: d3roPalette.text.primary }, + }} + /> + ) + })} + {showTagInput && ( + { + setTagInput(e.target.value) + if (tagFailure?.kind === 'add') setTagFailure(null) + }} + onKeyDown={handleTagKeyDown} + onBlur={() => { + if (!tagInput.trim()) setShowTagInput(false) + }} + autoFocus + placeholder={t('memo.tagPlaceholder')} + inputProps={{ 'aria-label': t('memo.addTag'), 'aria-invalid': tagFailure?.kind === 'add' }} + sx={{ + ...typoSx('small'), + height: 24, + px: 1, + minWidth: 160, + color: d3roPalette.text.primary, + bgcolor: d3roPalette.bg.input, + border: `1px solid ${tagFailure?.kind === 'add' ? d3roPalette.status.danger : d3roPalette.accent.main}`, + borderRadius: d3roRadius.small, + '& input::placeholder': { color: d3roPalette.text.tertiary, opacity: 1 }, + }} + /> + )} + {tagFailure && ( + + {`#${tagFailure.tag} · ${t('common.retry')}`} + + )} + + )} + + {/* 행동 줄 — 재사용 행동은 여기 한 곳에만 */} + + void handleCopy('primary')}> + {copied === 'primary' ? : } + {copyLabel} + + {originalText && ( + void handleCopy('original')}> + {copied === 'original' ? : } + {copyOriginalLabel} + + )} + {entry.duration > 0 && ( + + + + {audioState === 'playing' ? : } + {audioState === 'playing' ? t('history.stopAudio') : t('history.playAudio')} + + + + )} + + + + {meta.length > 0 && ( + + {meta.join(' · ')} + + )} + + onToggleFavorite(entry)} + sx={{ ...iconButtonSx, color: entry.isFavorite ? d3roPalette.status.warning : iconButtonSx.color }} + > + + + + + setShowTagInput(true)} + sx={iconButtonSx} + > + + + + + onDelete(entry)} + sx={{ ...iconButtonSx, '&:hover': { color: d3roPalette.status.danger, bgcolor: d3roPalette.status.dangerBg } }} + > + + + - + ) } diff --git a/apps/desktop/src/renderer/components/shared/PageHeader.tsx b/apps/desktop/src/renderer/components/shared/PageHeader.tsx index 83362ef..6739306 100644 --- a/apps/desktop/src/renderer/components/shared/PageHeader.tsx +++ b/apps/desktop/src/renderer/components/shared/PageHeader.tsx @@ -1,30 +1,47 @@ // src/renderer/components/shared/PageHeader.tsx -// 공유: 각인 스타일 페이지 헤더 (타이틀 + 카운트 + 옵션 액션) +// 공유: 화면 머리 — 제목(h1) · 개수 · 설명 · 오른쪽 행동. 모든 화면이 같은 자리·같은 크기로 시작한다. +// v4(2026-09-28): 11px 회색 라벨이던 제목을 실제 h1 으로. 메뉴 이름과 화면 제목을 같게 쓴다. -import { Box } from '@mui/material' -import { PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette } from '@d3ro/ui/theme' +import { Box, Typography } from '@mui/material' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' interface PageHeaderProps { title: string - count?: string + /** 제목 옆 개수·상태 (문자열 또는 작은 배지) */ + count?: React.ReactNode + /** 제목 아래 한 줄 설명 */ + description?: React.ReactNode + /** 오른쪽 행동 — 주 행동은 하나만 채운 버튼으로 */ action?: React.ReactNode } -export function PageHeader({ title, count, action }: PageHeaderProps): React.ReactElement { +export function PageHeader({ title, count, description, action }: PageHeaderProps): React.ReactElement { return ( - - - - {title} - - {count && ( - - {count} - + + + + + {title} + + {count !== undefined && count !== null && count !== '' && ( + + {count} + + )} + + {description && ( + + {description} + )} - {action} + {action && {action}} ) } diff --git a/apps/desktop/src/renderer/components/shared/SearchInput.tsx b/apps/desktop/src/renderer/components/shared/SearchInput.tsx index 7a36b23..f58f52b 100644 --- a/apps/desktop/src/renderer/components/shared/SearchInput.tsx +++ b/apps/desktop/src/renderer/components/shared/SearchInput.tsx @@ -1,93 +1,95 @@ // src/renderer/components/shared/SearchInput.tsx -// Precision Glass Search Bar with Tactile Clear & Keycap Indicator +// 공유: 화면 안 검색창. "/" 를 누르면 여기로 포커스가 온다(입력 중이 아닐 때) — 표시만 하고 동작하지 않던 +// 키캡을 실제 단축키로 만들었다(2026-09-28). Esc 는 검색어를 지우고, 비어 있으면 포커스를 놓는다. -import React from 'react' +import React, { useEffect, useRef } 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' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' export interface SearchInputProps { value: string onChange: (value: string) => void placeholder: string + /** 포커스 단축키. '/' 만 지원. 빈 문자열이면 끈다 */ shortcut?: string } -export function SearchInput({ - value, - onChange, - placeholder, - shortcut = '/', -}: SearchInputProps): React.ReactElement { +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) +} + +export function SearchInput({ value, onChange, placeholder, shortcut = '/' }: SearchInputProps): React.ReactElement { + const { t } = useI18n() + const inputRef = useRef(null) + + useEffect(() => { + if (shortcut !== '/') return undefined + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return + e.preventDefault() + inputRef.current?.focus() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [shortcut]) + return ( onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + if (value) onChange('') + else inputRef.current?.blur() + }} fullWidth sx={{ - mb: 2.5, + mb: 2, '& .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}`, - }, + bgcolor: d3roPalette.bg.input, + borderRadius: d3roRadius.button, + '& fieldset': { borderColor: d3roPalette.border.default }, + '&:hover fieldset': { borderColor: d3roPalette.border.strong }, + '&.Mui-focused fieldset': { borderColor: d3roPalette.accent.main, borderWidth: 1 }, }, '& .MuiInputBase-input': { - fontFamily: d3roFontSans, fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, - py: 1.25, - '&::placeholder': { - color: d3roPalette.text.disabled, - opacity: 1, - }, + py: 1, + '&::placeholder': { color: d3roPalette.text.tertiary, opacity: 1 }, }, }} slotProps={{ + htmlInput: { 'aria-label': placeholder, type: 'search' }, input: { startAdornment: ( - - + + ), endAdornment: value ? ( - onChange('')} - sx={{ - color: d3roPalette.text.inactive, - p: 0.5, - '&:hover': { color: d3roPalette.text.primary }, - }} - > + onChange('')} sx={{ p: 0.5 }}> ) : shortcut ? ( - + void } + /** ms. 기본 5000, 오류는 8000 */ + duration?: number + /** 시간이 다 되어 닫힐 때(행동을 누르지 않았을 때) — 지연 삭제 확정에 쓴다 */ + onExpire?: () => void +} + +interface NotifyValue { + notify: (options: NotifyOptions) => void +} + +const NotifyContext = createContext(null) + +export function NotifyProvider({ children }: { children: ReactNode }): React.ReactElement { + const [current, setCurrent] = useState<(NotifyOptions & { key: number }) | null>(null) + // 부수 효과(onExpire)는 상태 갱신 함수 밖에서 한 번만 — StrictMode 는 갱신 함수를 두 번 부른다. + const currentRef = useRef<(NotifyOptions & { key: number }) | null>(null) + const settledRef = useRef(true) + const keyRef = useRef(0) + + /** 현재 알림을 끝낸다. expire=true 면 행동을 누르지 않고 닫힌 것 — 지연 동작을 확정한다 */ + const settle = useCallback((expire: boolean) => { + const prev = currentRef.current + if (prev && !settledRef.current) { + settledRef.current = true + if (expire) prev.onExpire?.() + } + }, []) + + const finish = useCallback( + (expire: boolean) => { + settle(expire) + currentRef.current = null + setCurrent(null) + }, + [settle], + ) + + const notify = useCallback( + (options: NotifyOptions) => { + // 새 알림이 이전 알림을 밀어내면, 이전 알림의 지연 동작은 확정한다 + settle(true) + keyRef.current += 1 + const next = { ...options, key: keyRef.current } + currentRef.current = next + settledRef.current = false + setCurrent(next) + }, + [settle], + ) + + const value = useMemo(() => ({ notify }), [notify]) + + return ( + + {children} + { + if (reason === 'clickaway') return + finish(true) + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + { + current.action?.onClick() + finish(false) + }} + > + {current.action.label} + + ) : undefined + } + > + {current?.message ?? ''} + + + + ) +} + +export function useNotify(): NotifyValue['notify'] { + const value = useContext(NotifyContext) + if (!value) throw new Error('useNotify must be used inside NotifyProvider') + return value.notify +} diff --git a/apps/desktop/src/renderer/components/shell/AppSidebar.tsx b/apps/desktop/src/renderer/components/shell/AppSidebar.tsx new file mode 100644 index 0000000..727bca8 --- /dev/null +++ b/apps/desktop/src/renderer/components/shell/AppSidebar.tsx @@ -0,0 +1,255 @@ +// src/renderer/components/shell/AppSidebar.tsx +// 메인 창 사이드바 — 2026-09-28 정보 구조 개편. +// +// 위: 1급(홈·기록·회의) — Ctrl+1..3. 가운데: "도구"(AI 대화·명령어·사전·지식). 아래: 지원·설정·상태 한 줄. +// 모든 항목은 {status === 'opened' && ( - - {SUPPORT_COPY.openedNotice} + + {t('support.contact.opened')} )} {status === 'failed' && ( - - {SUPPORT_COPY.openFailed} + + {t('support.contact.openFailed')} )} - - {SUPPORT_COPY.directAddress}: {SUPPORT_EMAIL} + + {t('support.contact.address')}:{' '} + + {SUPPORT_EMAIL} + diff --git a/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx b/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx index 986bb71..33f0a87 100644 --- a/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx +++ b/apps/desktop/src/renderer/components/support/SupportFaqPanel.tsx @@ -1,56 +1,58 @@ // src/renderer/components/support/SupportFaqPanel.tsx // 지원 모달 — FAQ 검색 패널 (정적 안내만, IO 없음) +// v4(2026-09-28): 카드 목록 → 질문(본문)·답(보조) + 1px 구분선. 설명 11px → small. import React, { useMemo, useState } from 'react' -import { Box, TextField, Typography } from '@mui/material' +import { Box, InputAdornment, TextField, Typography } from '@mui/material' import { Search } from 'lucide-react' -import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme' -import { SUPPORT_COPY, SUPPORT_FAQS } from './support-copy' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import { SUPPORT_FAQ_KEYS } from './support-copy' export function SupportFaqPanel(): React.ReactElement { + const { t } = useI18n() const [searchQuery, setSearchQuery] = useState('') + const faqs = useMemo(() => SUPPORT_FAQ_KEYS.map((f) => ({ q: t(f.q), a: t(f.a) })), [t]) + const filteredFaqs = useMemo(() => { const query = searchQuery.trim().toLowerCase() - if (!query) return SUPPORT_FAQS - return SUPPORT_FAQS.filter((f) => f.q.toLowerCase().includes(query) || f.a.toLowerCase().includes(query)) - }, [searchQuery]) + if (!query) return faqs + return faqs.filter((f) => f.q.toLowerCase().includes(query) || f.a.toLowerCase().includes(query)) + }, [faqs, searchQuery]) return ( setSearchQuery(e.target.value)} + inputProps={{ 'aria-label': t('support.faq.search') }} InputProps={{ - startAdornment: , - }} - sx={{ - '& .MuiInputBase-root': { - bgcolor: 'var(--d3-bg-inset-soft)', - color: 'var(--d3-text-inverse)', - fontSize: '12px', - borderRadius: '10px', - }, + startAdornment: ( + + + + ), }} /> - + {filteredFaqs.length === 0 && ( - - {SUPPORT_COPY.faqEmpty} + + {t('support.faq.empty')} )} {filteredFaqs.map((faq) => ( - - Q. {faq.q} + + {faq.q} - + {faq.a} diff --git a/apps/desktop/src/renderer/components/support/SupportModal.tsx b/apps/desktop/src/renderer/components/support/SupportModal.tsx index 182d469..a215d8b 100644 --- a/apps/desktop/src/renderer/components/support/SupportModal.tsx +++ b/apps/desktop/src/renderer/components/support/SupportModal.tsx @@ -3,14 +3,14 @@ // // 환불 자격 조회·결제 취소·티켓 접수 백엔드가 없으므로 그런 기능을 흉내 내지 않는다. // 문의는 사용자의 메일 앱(mailto:)으로만 넘기고, 서버가 확인하지 않은 성공은 표시하지 않는다. +// v4(2026-09-28): 손으로 띄우던 Modal+blur 상자 → MUI Dialog 기본 표면, 제목 h2, 보라 강조 → 기본 탭. import React, { useMemo, useState } from 'react' -import { Modal, Box, Typography, IconButton, Tabs, Tab } from '@mui/material' -import { Headphones, HelpCircle, Mail, X } from 'lucide-react' -import { d3roPalette, d3roFontSans, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { Dialog, DialogContent, DialogTitle, IconButton, Tabs, Tab } from '@mui/material' +import { HelpCircle, Mail, X } from 'lucide-react' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { createElectronSupportChannel, type SupportChannel } from './support-contact' -import { SUPPORT_COPY } from './support-copy' import { SupportFaqPanel } from './SupportFaqPanel' import { SupportContactPanel } from './SupportContactPanel' @@ -32,74 +32,52 @@ export function SupportModal({ open, onClose, channel }: SupportModalProps): Rea ) return ( - - + - {/* Header */} - - - - - {SUPPORT_COPY.title} - - - - - - + {t('support.title')} + + + + - {/* Navigation Tabs */} - setTab(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.purple }, - }, - '& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple }, - }} - > - } iconPosition="start" label={SUPPORT_COPY.tabFaq} /> - } iconPosition="start" label={SUPPORT_COPY.tabContact} /> - + setTab(v)} + aria-label={t('support.title')} + sx={{ + px: 3, + minHeight: 'auto', + borderBottom: `1px solid ${d3roPalette.border.subtle}`, + '& .MuiTab-root': { + ...typoSx('compact'), + minHeight: 36, + py: 0, + px: 1.5, + color: d3roPalette.text.secondary, + '&.Mui-selected': { color: d3roPalette.text.primary, fontWeight: 500 }, + }, + }} + > + } iconPosition="start" label={t('support.tab.faq')} /> + } iconPosition="start" label={t('support.tab.contact')} /> + + {tab === 'faq' && } {tab === 'contact' && } - - + + ) } diff --git a/apps/desktop/src/renderer/components/support/support-copy.ts b/apps/desktop/src/renderer/components/support/support-copy.ts index dac98b6..d5bd25b 100644 --- a/apps/desktop/src/renderer/components/support/support-copy.ts +++ b/apps/desktop/src/renderer/components/support/support-copy.ts @@ -1,60 +1,25 @@ // src/renderer/components/support/support-copy.ts -// 지원 모달 문구 모음. -// -// TODO(i18n): 공용 로케일(packages/i18n/src/locales/*.json)에 `support.*` 키가 아직 없다. -// 문구를 이 한 파일에 모아 두었으니, 키를 추가할 때 이 객체를 t() 호출로 옮기면 된다. +// 지원 모달 문구의 번역 키 모음. 문구 자체는 로케일(`support.*`)에 있다. // 결제·환불·티켓 처리를 약속하는 문구는 넣지 않는다 (백엔드가 없다). +import type { TranslationKey } from '@d3ro/i18n' import type { SupportCategory } from './support-contact' -export interface SupportFaq { - q: string - a: string +export interface SupportFaqKeys { + q: TranslationKey + a: TranslationKey } -export const SUPPORT_FAQS: readonly SupportFaq[] = [ - { - q: '마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.', - a: '1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 "데스크톱 앱이 마이크에 액세스하도록 허용"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > STT 탭에서 실제로 쓰는 입력 마이크 장치가 선택되었는지 확인하세요.\n3. 그래도 해결되지 않으면 "문의하기" 탭에서 이메일로 알려 주세요.', - }, - { - q: 'NVIDIA CUDA GPU 가속을 어떻게 활성화하나요?', - a: 'NVIDIA 그래픽 카드와 CUDA 런타임이 준비되어 있으면 faster-whisper(CTranslate2) 엔진이 GPU를 감지해 GPU 모드로 동작합니다. 큰 모델일수록 VRAM이 더 필요합니다.', - }, - { - q: '환불은 어떻게 요청하나요?', - a: '환불과 결제 취소는 구매 화면에 안내된 조건과 해당 결제 처리자(Google Play 등)의 정책을 따릅니다. 앱 안에서 자동으로 환불되지는 않습니다. "문의하기" 탭에서 "결제 / 환불" 분류로 이메일을 보내 주시면 확인 후 답변드립니다.', - }, - { - q: '법인 전자세금계산서 발행은 어떻게 요청하나요?', - a: '"문의하기" 탭에서 "결제 / 환불" 분류로 사업자 정보와 결제 내역을 적어 이메일을 보내 주세요.', - }, +export const SUPPORT_FAQ_KEYS: readonly SupportFaqKeys[] = [ + { q: 'support.faq.mic.q', a: 'support.faq.mic.a' }, + { q: 'support.faq.gpu.q', a: 'support.faq.gpu.a' }, + { q: 'support.faq.refund.q', a: 'support.faq.refund.a' }, + { q: 'support.faq.invoice.q', a: 'support.faq.invoice.a' }, ] -export const SUPPORT_CATEGORY_LABELS: Record = { - audio: '마이크 / 오디오', - gpu: 'CUDA GPU / 모델 로딩 오류', - billing: '결제 / 환불', - general: '기타 문의', +export const SUPPORT_CATEGORY_LABEL_KEYS: Record = { + audio: 'support.category.audio', + gpu: 'support.category.gpu', + billing: 'support.category.billing', + general: 'support.category.general', } - -export const SUPPORT_COPY = { - title: '고객지원', - tabFaq: '자주 묻는 질문 (FAQ)', - tabContact: '문의하기', - faqSearchPlaceholder: '자주 묻는 질문 검색...', - faqEmpty: '일치하는 질문이 없습니다.', - contactIntro: - '문의는 이메일로 받습니다. 아래 내용을 채우고 버튼을 누르면 메일 앱에 초안이 열립니다. 메일 앱에서 직접 보내야 문의가 전달됩니다.', - fieldCategory: '분류', - fieldSubject: '문의 제목', - fieldSubjectPlaceholder: '문제를 요약해 주세요', - fieldDetails: '상세 내용', - fieldDetailsPlaceholder: '어떤 상황에서 문제가 생겼는지 적어 주세요...', - requiredError: '필수 입력 항목입니다.', - diagnosticsLabel: '메일 본문에 포함될 진단 정보', - submit: '메일 앱에서 문의 작성하기', - openedNotice: '메일 앱에 초안을 열었습니다. 메일 앱에서 보내기를 눌러야 문의가 전달됩니다.', - openFailed: '메일 앱을 열지 못했습니다. 아래 주소로 직접 이메일을 보내 주세요.', - directAddress: '지원 이메일', -} as const diff --git a/apps/desktop/src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx b/apps/desktop/src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx index 111ce00..ca23564 100644 --- a/apps/desktop/src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx +++ b/apps/desktop/src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx @@ -1,17 +1,17 @@ // src/renderer/components/voice-conversation/VoiceRecordingPanel.tsx -// Voice Conversation 몰입 녹음 패널 (리디자인 v2) -// 기존 9바 DOM waveform을 DS의 GradientWave(canvas 스펙트럼 웨이브)로 교체. +// Voice Conversation 녹음 패널 — GradientWave(canvas 스펙트럼 웨이브). +// v4(2026-09-28): 유휴 진폭 0(말하지 않을 때 파형이 살아있는 척 움직이지 않는다)·글로우 없음·평면 테두리. // // AudioLevelSource: // - VoiceConversationService가 listening 상태에서만 AUDIO_LEVEL 채널로 forwarding. // - level 값은 0.0~1.0. 구독자가 없는 thinking/speaking에는 Panel 자체가 언마운트되므로 // useEffect cleanup으로 subscription이 자동 해제됨. -// - 레벨 스무딩/유휴 진동은 GradientWave 내부에서 처리하므로 여기서는 raw level만 전달. +// - 레벨 스무딩은 GradientWave 내부에서 처리하므로 여기서는 raw level만 전달. import { useEffect, useRef, useState } from 'react' -import { Box } from '@mui/material' -import { GradientWave, Led, PhosphorText } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme' +import { Box, Typography } from '@mui/material' +import { GradientWave, Led } from '@d3ro/ui/components/ds' +import { d3roPalette, d3roFontMono, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' const WAVE_HEIGHT = 48 @@ -25,9 +25,9 @@ function formatDuration(ms: number): string { /** * Voice Conversation이 listening 상태일 때만 렌더되는 몰입 녹음 패널. - * - GradientWave 스펙트럼 웨이브 (audio-level 기반, 유휴 애니메이션 내장) - * - REC LED + elapsed 타이머 - * - "SPEAK NOW · PRESS ⏹ TO SEND" 힌트 (i18n) + * - GradientWave 스펙트럼 웨이브 (audio-level 기반, 유휴 진폭 0) + * - 녹음 표시 + 경과 시간 + * - "말씀하세요 · ⏹로 전송" 힌트 (i18n) */ export function VoiceRecordingPanel(): React.ReactElement { const { t } = useI18n() @@ -70,17 +70,17 @@ export function VoiceRecordingPanel(): React.ReactElement { }} > {/* REC LED + 타이머 */} - - - + + + {t('conversation.state.recording')} - + {/* 힌트 */} - - {`─ ${t('conversation.recording.hint')} ─`} - + + {t('conversation.recording.hint')} + ) } diff --git a/apps/desktop/src/renderer/navigation/app-navigation.tsx b/apps/desktop/src/renderer/navigation/app-navigation.tsx new file mode 100644 index 0000000..ca79d4a --- /dev/null +++ b/apps/desktop/src/renderer/navigation/app-navigation.tsx @@ -0,0 +1,101 @@ +// src/renderer/navigation/app-navigation.tsx +// 메인 창의 화면 이동 SSOT (2026-09-28 정보 구조 개편). +// +// 1급: 홈 · 기록 · 회의 — 데스크톱·모바일이 같은 순서·같은 이름을 쓴다. +// 2급 "도구": AI 대화 · 명령어 · 사전 · 지식. 설정은 전체 화면. +// 화면 사이에서 대상을 넘길 때(홈 → 특정 회의)는 params 로 넘기고, 받는 화면이 한 번 소비한다. + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import type { SettingsTabId } from './settingsTabs' + +export type AppRoute = + | 'home' + | 'records' + | 'meetings' + | 'conversation' + | 'commands' + | 'dictionary' + | 'knowledge' + | 'settings' + +/** 1급 화면 — 사이드바 위쪽, Ctrl+1..3 */ +export const PRIMARY_ROUTES: readonly AppRoute[] = ['home', 'records', 'meetings'] +/** 2급 "도구" 화면 */ +export const TOOL_ROUTES: readonly AppRoute[] = ['conversation', 'commands', 'dictionary', 'knowledge'] + +export interface RouteParams { + /** 회의 화면: 이 회의의 상세를 연다 */ + meetingSessionId?: string + /** 회의 화면: 바로 새 회의 녹음을 시작한다 */ + startMeeting?: boolean + /** 기록 화면: 이 검색어로 시작한다 */ + query?: string + /** 기록 화면: 이 기록을 펼쳐 보인다 */ + recordId?: string + /** 설정 화면: 이 탭을 연다 */ + settingsTab?: SettingsTabId +} + +interface NavigationState { + route: AppRoute + params: RouteParams + /** 같은 화면으로 다시 이동해도 받는 쪽이 params 를 다시 소비하도록 증가한다 */ + seq: number +} + +interface NavigationValue extends NavigationState { + navigate: (route: AppRoute, params?: RouteParams) => void + /** 직전 화면으로. 기록이 없으면 홈 */ + back: () => void +} + +const NavigationContext = createContext(null) + +export function AppNavigationProvider({ + initialRoute = 'home', + children, +}: { + initialRoute?: AppRoute + children: ReactNode +}): React.ReactElement { + const [state, setState] = useState({ route: initialRoute, params: {}, seq: 0 }) + const historyRef = useRef([]) + + const navigate = useCallback((route: AppRoute, params: RouteParams = {}) => { + setState((prev) => { + if (prev.route !== route) historyRef.current = [...historyRef.current.slice(-19), prev.route] + return { route, params, seq: prev.seq + 1 } + }) + }, []) + + const back = useCallback(() => { + const previous = historyRef.current.pop() ?? 'home' + setState((prev) => ({ route: previous, params: {}, seq: prev.seq + 1 })) + }, []) + + const value = useMemo(() => ({ ...state, navigate, back }), [state, navigate, back]) + return {children} +} + +export function useAppNavigation(): NavigationValue { + const value = useContext(NavigationContext) + if (!value) throw new Error('useAppNavigation must be used inside AppNavigationProvider') + return value +} + +/** + * 받는 화면이 params 를 한 번만 소비하게 돕는다. 이 화면으로의 이동(seq)마다 handler 가 한 번 불린다. + * 렌더 중이 아니라 이펙트에서 부르므로 StrictMode 이중 렌더에도 요청을 놓치지 않는다. + */ +export function useRouteRequest(route: AppRoute, handler: (params: RouteParams) => void): void { + const { route: current, params, seq } = useAppNavigation() + const handlerRef = useRef(handler) + handlerRef.current = handler + const consumedRef = useRef(-1) + useEffect(() => { + if (current !== route || consumedRef.current === seq) return + consumedRef.current = seq + handlerRef.current(params) + }, [current, route, params, seq]) +} diff --git a/apps/desktop/src/renderer/navigation/error-recovery.ts b/apps/desktop/src/renderer/navigation/error-recovery.ts new file mode 100644 index 0000000..bc5a09d --- /dev/null +++ b/apps/desktop/src/renderer/navigation/error-recovery.ts @@ -0,0 +1,187 @@ +// src/renderer/navigation/error-recovery.ts +// 렌더러 오류 표시·복구 정책의 단일 진입점 (순수 모듈 — React/DOM/IPC 없음). +// +// 예전에는 AppLayout(voice:error)과 MeetingModePage(parseMeetingError)가 각자 매직 넘버로 +// 오류를 분류했고, 그 숫자가 @d3ro/core ErrorCode 와 맞지 않았다: +// - 101(STT 모델 없음) → 닫을 수 없는 온보딩 모달을 열어 메인 창을 가뒀다. +// - 1400 을 '충돌'로(실제 880/771), 200-220(TTS)을 '마이크'로 봤고 400대 오디오 코드는 못 잡았다. +// - 882(후처리 실패)를 '회의를 시작할 수 없습니다'로 보여 주고 주 버튼이 새 녹음을 강제로 시작했다. +// 이제 분류는 ErrorCode 열거형 멤버로만 하고, 화면은 여기서 돌려준 결정을 그리기만 한다. +// 새 ErrorCode 는 이 파일의 분류 함수만 고치면 된다(호출부는 category·recoveries 만 본다). + +import { ErrorCode } from '@d3ro/core/errors' +import { classifyVoiceError, type VoiceErrorMessageId } from '@d3ro/core/voice-error-message' +import type { TranslationKey } from '@d3ro/i18n' +import type { SettingsTabId } from './settingsTabs' + +/** 오류가 난 자리 — 같은 코드라도 자리에 따라 복구가 다르다. */ +export type ErrorContext = 'voice' | 'meetingStart' | 'meetingProcessing' + +export type ErrorCategory = + | 'sttModelMissing' + | 'sttEngine' + | 'stt' + | 'audio' + | 'collision' + | 'processing' + | 'llmSkipped' + | 'general' + +/** + * 사용자에게 제시할 수 있는 복구 동작. + * - openSettings: 해당 설정 탭 딥링크 + * - forceRestart: 남은 세션을 강제 초기화하고 녹음을 다시 시작 — '시작' 실패에서만 허용 + */ +export type RecoveryAction = { kind: 'openSettings'; tab: SettingsTabId } | { kind: 'forceRestart' } + +export interface ErrorInput { + code?: number | string + message?: string + severity?: 'error' | 'warning' + context: ErrorContext +} + +export interface ErrorPresentation { + category: ErrorCategory + /** 원문 대신 보여 줄 i18n 문구. null 이면 호출부가 원문(또는 자체 문구)을 쓴다. */ + messageKey: TranslationKey | null + /** 제시할 복구 동작들(보조 → 주 순서). 빈 배열이면 닫기만 한다. */ + recoveries: readonly RecoveryAction[] +} + +const OPEN_STT_SETTINGS: RecoveryAction = { kind: 'openSettings', tab: 'stt' } +const OPEN_AUDIO_SETTINGS: RecoveryAction = { kind: 'openSettings', tab: 'audio' } +const FORCE_RESTART: RecoveryAction = { kind: 'forceRestart' } + +/** IPC 경계에서 온 코드를 숫자로 정규화한다. 숫자로 읽을 수 없으면 null. */ +export function normalizeErrorCode(code: number | string | undefined): number | null { + if (code === undefined || code === '') return null + const n = typeof code === 'number' ? code : Number(code) + return Number.isInteger(n) ? n : null +} + +// ErrorCode 대역(packages/core/src/errors.ts): STT 100-199, TTS 200-299, LLM 300-399, Audio 400-499. +function isSttCode(code: number): boolean { + return code >= ErrorCode.STTEngineNotInstalled && code < ErrorCode.TTSEngineNotInstalled +} + +function isAudioCode(code: number): boolean { + return code >= ErrorCode.AudioDeviceNotFound && code <= ErrorCode.AudioBufferOverflow +} + +function isSttEngineCode(code: number): boolean { + return ( + code === ErrorCode.STTModelLoadFailed || + code === ErrorCode.STTSidecarSpawnFailed || + code === ErrorCode.STTSidecarCrashed || + code === ErrorCode.STTSidecarCommunicationFailed + ) +} + +function isCollisionCode(code: number): boolean { + return code === ErrorCode.MeetingAlreadyRecording || code === ErrorCode.CaptionAlreadyActive +} + +// ── voice:error ── +// 받아쓰기 오류 문구 종류는 @d3ro/core 가 한 번 분류한다(녹음 팁도 같은 분류). 여기서는 번역 키만 고른다. +const VOICE_MESSAGE_KEY: Record, TranslationKey> = { + modelMissing: 'voice.error.modelMissing', + engine: 'voice.error.engine', + micOpen: 'voice.error.micOpen', + micLost: 'voice.error.micLost', + noSpeech: 'voice.error.noSpeech', + tooShort: 'voice.error.tooShort', + transcription: 'voice.error.transcription', + captionActive: 'voice.error.captionActive', + llmFailed: 'voice.error.llmFailed', +} + +function presentVoiceError(code: number | null, severity: 'error' | 'warning'): ErrorPresentation { + // 모델이 없으면 받는 곳(설정 → STT)으로 보낸다. 온보딩은 모델을 받지 못하고 닫을 수도 없었다. + if (code === ErrorCode.STTModelNotFound) { + return { category: 'sttModelMissing', messageKey: 'voice.error.modelMissing', recoveries: [OPEN_STT_SETTINGS] } + } + if (code !== null && isSttEngineCode(code)) { + return { category: 'sttEngine', messageKey: 'voice.error.engine', recoveries: [] } + } + if (severity === 'warning' && code === ErrorCode.LLMServerUnreachable) { + return { category: 'llmSkipped', messageKey: 'voice.warning.llmSkipped', recoveries: [] } + } + const id = classifyVoiceError(code) + // 마이크를 열지 못했으면 입력 장치를 고를 수 있는 곳(설정 → 오디오)으로 + if (id === 'micOpen') { + return { category: 'audio', messageKey: VOICE_MESSAGE_KEY.micOpen, recoveries: [OPEN_AUDIO_SETTINGS] } + } + if (id !== null && id !== 'quota') { + return { category: code !== null && isAudioCode(code) ? 'audio' : 'general', messageKey: VOICE_MESSAGE_KEY[id], recoveries: [] } + } + return { category: 'general', messageKey: null, recoveries: [] } +} + +// ── 회의 시작 실패 ── +// 코드가 없는 경로(IPC 호출 자체의 catch)를 위해 예전 키워드 판별을 폴백으로 남긴다. +function classifyStartMessage(message: string): ErrorCategory { + const msg = message.toLowerCase() + if (['활성', '자막', '이미', 'already', 'collision'].some((k) => msg.includes(k))) return 'collision' + if (['stt', 'whisper', 'sidecar', 'python', 'model'].some((k) => msg.includes(k))) return 'stt' + if (['audio', 'mic', '장치', 'sox'].some((k) => msg.includes(k))) return 'audio' + return 'general' +} + +function classifyStartCode(code: number): ErrorCategory | null { + if (code === ErrorCode.MeetingProcessingFailed) return 'processing' + if (isCollisionCode(code)) return 'collision' + if (isSttCode(code)) return 'stt' + if (isAudioCode(code)) return 'audio' + return null +} + +function startRecoveries(category: ErrorCategory): readonly RecoveryAction[] { + switch (category) { + case 'stt': + case 'sttModelMissing': + case 'sttEngine': + return [OPEN_STT_SETTINGS, FORCE_RESTART] + case 'audio': + return [OPEN_AUDIO_SETTINGS, FORCE_RESTART] + case 'processing': + return [] + default: + return [FORCE_RESTART] + } +} + +function presentMeetingStartError(code: number | null, message: string): ErrorPresentation { + const category = (code !== null ? classifyStartCode(code) : null) ?? classifyStartMessage(message) + return { category, messageKey: null, recoveries: startRecoveries(category) } +} + +/** + * 오류 하나를 어떻게 보여 주고 어떤 복구를 제시할지 결정한다. + * 회의 후처리 실패(meetingProcessing)는 코드·문구와 무관하게 'processing' 이고 강제 재시작을 절대 제시하지 않는다 — + * 실패한 회의는 이미 'error' 로 기록돼 목록에 남고, 새 녹음을 여는 것은 복구가 아니다. + */ +export function presentError(input: ErrorInput): ErrorPresentation { + const code = normalizeErrorCode(input.code) + switch (input.context) { + case 'voice': + return presentVoiceError(code, input.severity ?? 'error') + case 'meetingProcessing': + return { category: 'processing', messageKey: null, recoveries: [] } + case 'meetingStart': + return presentMeetingStartError(code, input.message ?? '') + } +} + +/** 복구 동작 중 설정 딥링크 탭(있으면). */ +export function settingsTabOf(presentation: ErrorPresentation): SettingsTabId | null { + for (const r of presentation.recoveries) { + if (r.kind === 'openSettings') return r.tab + } + return null +} + +/** 강제 재시작을 제시하는지. */ +export function offersForceRestart(presentation: ErrorPresentation): boolean { + return presentation.recoveries.some((r) => r.kind === 'forceRestart') +} diff --git a/apps/desktop/src/renderer/pages/CommandsPage.tsx b/apps/desktop/src/renderer/pages/CommandsPage.tsx index ff5aa46..8ca9522 100644 --- a/apps/desktop/src/renderer/pages/CommandsPage.tsx +++ b/apps/desktop/src/renderer/pages/CommandsPage.tsx @@ -1,5 +1,9 @@ // src/renderer/pages/CommandsPage.tsx -// Custom LLM Commands, Voice Keywords & Multi-Step LLM Pipeline Chains +// 명령어 — 받아쓴 문장을 AI 로 어떻게 다듬을지 고르는 화면(2급 "도구"). +// +// 2026-09-28 개편: "활성 파이프라인" 히어로를 요약 패널 한 개(현재 후처리 + 번호 단계)로, 카드 그리드를 한 열 목록으로. +// 카드 클릭이 곧 활성화였던 숨은 조작을 라디오로 드러냈다. 테스트와 딕테이션 템플릿은 아래 접힌 구획. +// 요약은 main 의 경로 규칙(@d3ro/core/voice-llm-route)과 같게 판단한다 — 지시문은 기본 액션이 'custom' 일 때만 쓰인다. import React, { useState, useEffect, useCallback, useRef } from 'react' import { @@ -8,69 +12,60 @@ import { DialogTitle, DialogContent, DialogActions, + FormControlLabel, + IconButton, + Radio, + RadioGroup, TextField, Tooltip, Typography, - IconButton, } from '@mui/material' -import { - Plus, - Trash2, - Pencil, - CircleCheck, - Play, - Zap, - ArrowRight, - Sparkles, - CheckCircle2, - Mic, - Cpu, - ClipboardCopy, - TriangleAlert, -} from 'lucide-react' -import { - MetalCard, - PhosphorText, - Led, - PhysicalButton, - DoubleBezelCard, - TactileBadge, -} from '@d3ro/ui/components/ds' -import { PageHeader, EmptyStateCard } from '../components/shared' -import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { Plus, Trash2, Pencil, Play } from 'lucide-react' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { PageHeader, EmptyStateCard, useNotify } from '../components/shared' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { INSTRUCTION_LIMITS } from '@d3ro/core/instruction-policy' +import type { CustomInstruction, LLMActionSelection } from '@d3ro/core/types' import { commandSaveError } from './commands/command-form' import { useI18n } from '@d3ro/i18n' import { TemplateSection } from '../components/TemplateSection' +import { ToolLoadError, ToolLoading, ToolRow, ToolSection, TOOL_PAGE_SX, TOOL_TEXT_SX } from './tools/ToolSection' +import { useDeferredDelete } from './tools/useDeferredDelete' +import { + COMMAND_NONE, + commandSelection, + commandTestRequest, + llmActionLabelKey, + resolveCommandPipeline, + withoutHidden, + type CommandPipeline, +} from './tools/tools-model' -interface CustomInstruction { - id: string - name: string - description: string - prompt: string - icon: string - isBuiltin: boolean - order: number +interface PipelineConfig { + defaultLLMAction: LLMActionSelection + activeInstructionId: string | null } export function CommandsPage(): React.ReactElement { const { t } = useI18n() + const notify = useNotify() + const { hidden, remove } = useDeferredDelete() const [instructions, setInstructions] = useState([]) const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(false) + const [config, setConfig] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) const [editId, setEditId] = useState(null) const [formName, setFormName] = useState('') const [formDesc, setFormDesc] = useState('') const [formPrompt, setFormPrompt] = useState('') - const [activeId, setActiveId] = useState(null) const [saving, setSaving] = useState(false) /** 저장 거부 사유 — 대화상자 안에 보여 준다 */ const [formError, setFormError] = useState(null) const isSavingRef = useRef(false) - const isDeletingRef = useRef(false) - // Interactive Pipeline Test State + // 테스트 실행 const [testInput, setTestInput] = useState('') const [testResult, setTestResult] = useState(null) const [testError, setTestError] = useState(null) @@ -78,46 +73,56 @@ export function CommandsPage(): React.ReactElement { const loadData = useCallback(async () => { setLoading(true) - const [instrResult, configResult] = await Promise.all([ - window.electronAPI.instruction.getAll(), - window.electronAPI.config.getAll(), - ]) - if (instrResult.success) { - setInstructions(instrResult.data as CustomInstruction[]) + try { + const [instrResult, configResult] = await Promise.all([ + window.electronAPI.instruction.getAll(), + window.electronAPI.config.getAll(), + ]) + // preload 가 unknown[] 로 선언한다(main 은 CustomInstruction[] 를 돌려준다) + if (instrResult.success) setInstructions(instrResult.data as CustomInstruction[]) + setLoadError(!instrResult.success) + if (configResult.success) { + setConfig({ + defaultLLMAction: configResult.data.defaultLLMAction, + activeInstructionId: configResult.data.activeInstructionId || null, + }) + } + } catch { + setLoadError(true) + } finally { + setLoading(false) } - if (configResult.success) { - const cfg = configResult.data as Record - setActiveId((cfg['activeInstructionId'] as string) ?? null) - } - setLoading(false) }, []) useEffect(() => { - loadData() - const unsub = window.electronAPI.app.onDataChanged((data) => { - if (data.type === 'command-changed' && data.activeId) { - setActiveId(data.activeId) - } - loadData() + void loadData() + return window.electronAPI.app.onDataChanged(() => { + void loadData() }) - return unsub }, [loadData]) - const handleActivate = (id: string) => { - const newId = activeId === id ? null : id - setActiveId(newId) - if (newId) { - window.electronAPI.config.set({ - key: 'activeInstructionId' as keyof import('@d3ro/core/types').AppConfig, - value: newId as never, - }) - window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' }) - } else { - window.electronAPI.config.set({ - key: 'activeInstructionId' as keyof import('@d3ro/core/types').AppConfig, - value: '' as never, - }) - window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' }) + const visible = withoutHidden(instructions, hidden) + const pipeline: CommandPipeline | null = config ? resolveCommandPipeline(config, visible) : null + const selection = pipeline ? commandSelection(pipeline) : '' + + /** 라디오 선택 — 설정 두 개를 함께 바꾼다. 실패하면 되돌리고 알린다 */ + const handleSelect = async (value: string): Promise => { + if (!config) return + const previous = config + const next: PipelineConfig = + value === COMMAND_NONE + ? { defaultLLMAction: 'none', activeInstructionId: null } + : { defaultLLMAction: 'custom', activeInstructionId: value } + setConfig(next) + try { + const results = await Promise.all([ + window.electronAPI.config.set({ key: 'activeInstructionId', value: next.activeInstructionId ?? '' }), + window.electronAPI.config.set({ key: 'defaultLLMAction', value: next.defaultLLMAction }), + ]) + if (results.some((r) => !r.success)) throw new Error('config:set failed') + } catch { + setConfig(previous) + notify({ message: t('tools.commands.selectFailed'), severity: 'error' }) } } @@ -164,7 +169,7 @@ export function CommandsPage(): React.ReactElement { return } setDialogOpen(false) - loadData() + void loadData() } catch (err: unknown) { setFormError(err instanceof Error ? err.message : String(err)) } finally { @@ -173,41 +178,35 @@ export function CommandsPage(): React.ReactElement { } } - const handleDelete = async (id: string) => { - if (isDeletingRef.current) return - isDeletingRef.current = true - try { - await window.electronAPI.instruction.delete({ id }) - if (activeId === id) { - setActiveId(null) - window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' }) - } - loadData() - } finally { - isDeletingRef.current = false - } + // 한 건 삭제는 확인 대신 "삭제했습니다 · 되돌리기" — 알림이 닫힐 때 실제로 지운다. + const handleDelete = (inst: CustomInstruction) => { + const wasSelected = config?.defaultLLMAction === 'custom' && config.activeInstructionId === inst.id + remove({ + id: inst.id, + message: t('tools.commands.deleted', { name: inst.name }), + commit: async () => { + const res = await window.electronAPI.instruction.delete({ id: inst.id }) + if (!res.success) return false + // 쓰던 명령을 지웠으면 원문 그대로 넣기로 돌린다(예전 동작 유지) + if (wasSelected) await window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' }) + return true + }, + onCommitted: () => void loadData(), + }) } - // 이 벤치는 실제 받아쓰기와 같은 경로(llm.process → processText)를 탄다. - // 지시문 원문을 customPrompt로 넘기면 메인 프로세스가 플레이스홀더 치환과 - // 시스템 프롬프트 배치를 처리한다 — 치환 규칙을 렌더러에 복제하지 않는다. - const handleRunPipelineTest = async () => { - if (!testInput.trim()) return + // 실제 받아쓰기와 같은 경로(llm.process → processText)를 탄다. 지시문 원문을 customPrompt 로 넘기면 + // 메인 프로세스가 플레이스홀더 치환과 시스템 프롬프트 배치를 처리한다 — 치환 규칙을 렌더러에 복제하지 않는다. + const testRequest = pipeline ? commandTestRequest(pipeline, testInput) : null + const handleRunTest = async () => { + if (!testRequest?.ok || testing) return setTesting(true) setTestResult(null) setTestError(null) try { - const activeInst = instructions.find((i) => i.id === activeId) - const res = await window.electronAPI.llm.process( - activeInst - ? { text: testInput.trim(), action: 'custom', customPrompt: activeInst.prompt } - : { text: testInput.trim(), action: 'refine' }, - ) - if (res.success) { - setTestResult(res.data.processedText) - } else { - setTestError(res.error.message || t('popup.error.default')) - } + const res = await window.electronAPI.llm.process(testRequest.params) + if (res.success) setTestResult(res.data.processedText) + else setTestError(res.error.message || t('popup.error.default')) } catch (error) { setTestError(error instanceof Error ? error.message : t('popup.error.default')) } finally { @@ -215,415 +214,188 @@ export function CommandsPage(): React.ReactElement { } } - const activeInstruction = instructions.find((i) => i.id === activeId) + const testBlockedKey = + testRequest && !testRequest.ok && testRequest.reason !== 'empty' + ? testRequest.reason === 'raw' + ? 'tools.commands.test.blockedRaw' + : testRequest.reason === 'chain' + ? 'tools.commands.test.blockedChain' + : 'tools.commands.test.blockedMissing' + : null return ( - - }> - {t('commands.add')} - - } - /> + + + }> + {t('tools.commands.add')} + + } + /> + {pipeline && } + - {/* ── 1. Active Command Hero Cockpit & Pipeline Architecture ── */} - - - - - - - - - {t('commands.activeCommand').toUpperCase()} - - - {activeInstruction ? activeInstruction.name : t('commands.none')} - - - - - - {activeInstruction ? 'PIPELINE ACTIVE' : 'RAW DICTATION MODE'} - - - - {/* Visual Pipeline Flow Schematic */} - - {/* Step 1 */} - - - - - - - STEP 01 - - - faster-whisper - - - - - - {/* Step 2 */} - - - - - - - STEP 02 - - - Phonetic Fix - - - - - - {/* Step 3 */} - - - - - - - STEP 03 - - - {activeInstruction ? activeInstruction.name : 'No LLM Polish'} - - - - - - {/* Step 4 */} - - - - - - - STEP 04 - - - Active Window - - - - - - - {/* ── 2. Commands Grid ──────────────────────────────────────── */} - - - - AVAILABLE AI INSTRUCTIONS ({instructions.length}) - - - - {loading ? ( - - {t('common.loading')} - - ) : instructions.length === 0 ? ( - + + {loadError && void loadData()} />} + {loading && instructions.length === 0 ? ( + ) : ( - - {instructions.map((inst) => { - const isActive = inst.id === activeId + void handleSelect(value)} + aria-label={t('tools.commands.listTitle')} + > + + } + sx={{ flex: 1, minWidth: 0, m: 0, gap: 0.5 }} + label={ + + + {t('tools.commands.none')} + + {t('tools.commands.noneHint')} + + } + /> + + {visible.map((inst) => { + const isActive = inst.id === selection return ( - handleActivate(inst.id)} - sx={{ - cursor: 'pointer', - borderRadius: d3roRadius.card, - border: `1px solid ${isActive ? d3roPalette.accent.main : 'transparent'}`, - transition: 'all 0.2s ease', - }} - > - - - - - {isActive ? ( - - ) : ( - - )} - - - - {inst.name} - - - {inst.description} - + + } + sx={{ flex: 1, minWidth: 0, m: 0, gap: 0.5 }} + label={ + + + {inst.name} + {isActive && {t('tools.commands.inUse')}} + {inst.isBuiltin && {t('tools.builtin')}} + {inst.description && {inst.description}} - - e.stopPropagation()}> - - openEdit(inst)} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, - }} - > - - - - {!inst.isBuiltin && ( - - handleDelete(inst.id)} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg }, - }} - > - - - - )} - - - - + } + /> + + + openEdit(inst)} + > + + + + {!inst.isBuiltin && ( + + handleDelete(inst)} + > + + + + )} + + ) })} + + )} + {!loading && !loadError && visible.length === 0 && ( + + )} - + - {/* ── 3. Interactive Pipeline Test Sandbox ───────────────────── */} - - - PIPELINE TEST BENCH - - - - - setTestInput(e.target.value)} - placeholder="테스트할 음성 전사 문장을 입력하거나 마이크로 받아쓰기 해보세요..." - size="small" - fullWidth - sx={{ - '& .MuiOutlinedInput-root': { - fontFamily: d3roFontSans, - fontSize: '13px', - bgcolor: d3roPalette.bg.inset, - borderRadius: d3roRadius.inner, - }, - }} - /> - - - {testing ? 'Processing...' : 'Run Pipeline'} - - - - {testResult && ( - - - - {testResult} - - - )} - - {testError && ( - - - - {testError} - - - )} - - - - - {/* ── 4. Voice Keyword Triggers Section ─────────────────────── */} - - - - - {/* Add / Edit Dialog */} - setDialogOpen(false)} - maxWidth="sm" - fullWidth - PaperProps={{ - sx: { - bgcolor: d3roPalette.bg.card, - borderRadius: d3roRadius.doubleBezelOuter, - border: `1px solid ${d3roPalette.glass.hairlineStrong}`, - boxShadow: d3roShadow.dialog, - p: 1, - }, - }} + - + + setTestInput(e.target.value)} + placeholder={t('tools.commands.test.placeholder')} + slotProps={{ htmlInput: { 'aria-label': t('tools.commands.test.inputLabel') } }} + size="small" + fullWidth + multiline + maxRows={4} + /> + void handleRunTest()} + disabled={!testRequest?.ok || testing} + startIcon={} + sx={{ whiteSpace: 'nowrap', flexShrink: 0 }} + > + {testing ? t('tools.commands.test.running') : t('tools.commands.test.run')} + + + {testBlockedKey && {t(testBlockedKey)}} + {testResult !== null && ( + + {t('tools.commands.test.result')} + + {testResult} + + + )} + {testError && ( + + {t('tools.commands.test.failed', { reason: testError })} + + )} + + + + + + + {/* 추가·편집 */} + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editId ? t('commands.editTitle') : t('commands.addTitle')} - + { setFormName(e.target.value); setFormError(null) }} fullWidth autoFocus - inputProps={{ maxLength: INSTRUCTION_LIMITS.name }} - sx={{ mt: 1.5 }} + slotProps={{ htmlInput: { maxLength: INSTRUCTION_LIMITS.name } }} + sx={{ mt: 1 }} /> { setFormDesc(e.target.value); setFormError(null) }} fullWidth - inputProps={{ maxLength: INSTRUCTION_LIMITS.description }} + slotProps={{ htmlInput: { maxLength: INSTRUCTION_LIMITS.description } }} sx={{ mt: 2.5 }} /> {formError && ( - + {formError} - + )} - + setDialogOpen(false)}> {t('common.cancel')} - - {saving ? t('common.saving') || '저장 중...' : t('common.save')} + void handleSave()} disabled={!formName.trim() || !formPrompt.trim() || saving}> + {saving ? t('common.saving') : t('common.save')} @@ -656,13 +428,65 @@ export function CommandsPage(): React.ReactElement { ) } -function VoiceKeywordsSection(): React.ReactElement { +/** 지금 받아쓰기가 거치는 단계 — 조용한 요약 패널 하나 */ +function PipelineSummary({ pipeline }: { pipeline: CommandPipeline }): React.ReactElement { + const { t } = useI18n() + const current = + pipeline.kind === 'raw' + ? t('tools.commands.none') + : pipeline.kind === 'instruction' + ? pipeline.instruction.name + : pipeline.kind === 'instruction-missing' + ? t('tools.commands.pipeline.missing') + : t(llmActionLabelKey(pipeline.action)) + const llmStep = + pipeline.kind === 'raw' + ? t('tools.commands.pipeline.stepRaw') + : pipeline.kind === 'instruction' + ? t('tools.commands.pipeline.stepInstruction', { name: pipeline.instruction.name }) + : pipeline.kind === 'instruction-missing' + ? t('tools.commands.pipeline.stepMissing') + : t('tools.commands.pipeline.stepAction', { action: t(llmActionLabelKey(pipeline.action)) }) + const steps = [t('tools.commands.pipeline.stepStt'), llmStep, t('tools.commands.pipeline.stepInsert')] + return ( - - - VOICE KEYWORD TRIGGERS & FILLER FILTER - - + + + {t('tools.commands.pipeline.current')}{' '} + + {current} + + + + {steps.map((step) => ( + + {step} + + ))} + ) } diff --git a/apps/desktop/src/renderer/pages/DashboardPage.tsx b/apps/desktop/src/renderer/pages/DashboardPage.tsx deleted file mode 100644 index 805d799..0000000 --- a/apps/desktop/src/renderer/pages/DashboardPage.tsx +++ /dev/null @@ -1,757 +0,0 @@ -// src/renderer/pages/DashboardPage.tsx -// v3 "Tactile Liquid & Midnight Glass" Voice Cockpit -// High-End Agency Architecture: DoubleBezel Hero / 4-Tile Bento / Multi-Engine Hub / Telemetry - -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Box, Typography } from '@mui/material' -import { - Mic, - BookOpen, - CalendarDays, - Flame, - Settings2, - KeyRound, - BarChart3, - Star, - Check, - Cpu, - Zap, - Activity, - Radio, - ArrowUpRight, -} from 'lucide-react' -import { - GradientWave, - Led, - MetalCard, - PhosphorText, - PhysicalButton, - StatRing, - TiltCard, - DoubleBezelCard, - TactileBadge, - AudioVisualizerBar, -} from '@d3ro/ui/components/ds' -import { EmptyStateCard, HistoryEntryCard } from '../components/shared' -import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo } from '@d3ro/ui/theme' -import { useI18n } from '@d3ro/i18n' -import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters' -import { BindingKeycaps } from '../components/keybinding/Keycap' -import { useKeyBindingMap } from '../hooks/useKeyBindingMap' -import { FileDropZone } from '../components/FileDropZone' -import { useInputInsights } from '../hooks/useInputInsights' -import type { StatsSummary, HistoryEntry, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types' - -/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */ -function CardHeader({ - title, - right, - icon, -}: { - title: string - right?: React.ReactNode - icon?: React.ReactNode -}): React.ReactElement { - return ( - - - {icon && {icon}} - {title} - - {right} - - ) -} - -/** 그라디언트 프로그레스 바 */ -function GradientBar({ - ratio, - alt = false, -}: { - /** 0~1 (무제한이면 1) */ - ratio: number - alt?: boolean -}): React.ReactElement { - return ( - - - - ) -} - -export function DashboardPage(): React.ReactElement { - const { t, formatRelativeDate } = useI18n() - const [stats, setStats] = useState(null) - const [history, setHistory] = useState([]) - const [ollamaConnected, setOllamaConnected] = useState(false) - const [llmModel, setLlmModel] = useState(null) - const bindingMap = useKeyBindingMap() - const dictationBinding = bindingMap?.dictation[0] ?? null - const inputInsights = useInputInsights(7) - const [captionState, setCaptionState] = useState('inactive') - const [audioLevel, setAudioLevel] = useState(0) - const audioDecayRef = useRef | null>(null) - const [licenseTier, setLicenseTier] = useState('free') - const [usageQuotas, setUsageQuotas] = useState([]) - const [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null) - const statsRef = useRef(null) - - const loadData = useCallback(() => { - window.electronAPI.stats.getSummary().then((r) => { - if (r.success) setStats(r.data) - }) - window.electronAPI.llm.getStatus().then((r) => { - if (r.success) setOllamaConnected(r.data.connectionState === 'connected') - }) - window.electronAPI.llm.getActiveModel().then((r) => { - if (r.success) setLlmModel(r.data) - }) - window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => { - if (r.success) setHistory(r.data.entries) - }) - window.electronAPI.caption.getState().then((r) => { - if (r.success) setCaptionState(r.data) - }) - window.electronAPI.license.getInfo().then((r) => { - if (r.success) setLicenseTier(r.data.tier) - }) - window.electronAPI.license.getAllUsage().then((r) => { - if (r.success) setUsageQuotas(r.data) - }) - window.electronAPI.llm.premium.getStatus().then((r) => { - if (r.success) setPremiumStatus(r.data) - }) - }, []) - - useEffect(() => { - loadData() - const interval = setInterval(loadData, 30000) - const unsub = window.electronAPI.app.onDataChanged(() => { - loadData() - }) - const unsubCaption = window.electronAPI.caption.onStateChanged((e) => { - setCaptionState(e.state) - }) - const unsubAudio = window.electronAPI.voice.onAudioLevel((e) => { - setAudioLevel(e.level) - }) - // 오디오 이벤트가 없을 때 서서히 감쇠 - audioDecayRef.current = setInterval(() => { - setAudioLevel((prev) => (prev > 0.01 ? prev * 0.85 : 0)) - }, 100) - return () => { - clearInterval(interval) - if (audioDecayRef.current) clearInterval(audioDecayRef.current) - unsub() - unsubCaption() - unsubAudio() - } - }, [loadData]) - - // 날짜별 그룹핑 - const groupedHistory = useMemo(() => { - const groups: Record = {} - for (const entry of history) { - const key = getDateKey(entry.createdAt) - if (!groups[key]) { - groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] } - } - groups[key].entries.push(entry) - } - return Object.values(groups) - }, [history, formatRelativeDate]) - - const services = useMemo( - () => [ - { name: t('service.sttEngine'), status: t('service.ready'), ok: true }, - { - name: t('service.ollamaLlm'), - status: ollamaConnected ? t('service.connected') : t('service.offline'), - ok: ollamaConnected, - }, - { - name: t('service.premiumLlm'), - status: premiumStatus?.available ? t('service.connected') : t('service.notConfigured'), - ok: premiumStatus?.available ?? false, - }, - { name: t('service.hotkeyHook'), status: t('service.active'), ok: true }, - { name: t('service.audioInput'), status: t('service.standby'), ok: true }, - ], - [t, ollamaConnected, premiumStatus], - ) - - const allOk = services.every((s) => s.ok || s.name === t('service.premiumLlm')) - const now = new Date() - const weekday = now.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase() - - const openSettings = useCallback(() => { - window.dispatchEvent(new Event('d3ro:open-settings')) - }, []) - - const statTiles = [ - { - ring: 'blue' as const, - icon: , - label: t('dashboard.recordingTime'), - value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), - unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), - sub: t('dashboard.sessionsToday'), - }, - { - ring: 'accent' as const, - icon: , - label: t('dashboard.recognizedWords'), - value: formatNumber(stats?.totalWordCount ?? 0), - unit: t('dashboard.total'), - sub: null, - }, - { - ring: 'green' as const, - icon: , - label: t('dashboard.sessionsToday'), - value: `${stats?.todaySessionCount ?? 0}`, - unit: t('dashboard.sessions'), - sub: null, - }, - { - ring: 'orange' as const, - icon: , - label: t('dashboard.streakUse'), - value: `${stats?.streakDays ?? 0}`, - unit: t('dashboard.days'), - sub: null, - }, - ] - - return ( - - {/* ── 1. 히어로 더블-베젤 코크핏 ─────────────────────────── */} - - {/* 상단 라인: 시스템 텔레메트리 칩 & 실시간 상태 */} - - - - {allOk ? t('dashboard.systemOk').toUpperCase() : t('dashboard.systemIssue').toUpperCase()} - - - 100% LOCAL PRIVACY - - - - - - {now.toLocaleDateString()} - - {weekday} - - - - {/* 메인 히어로 그리드 */} - - {/* 좌: 메인 타이틀 + 단축키 힌트 */} - - - {t('dashboard.heroIdle')} - - - - - {dictationBinding ? t('keybinding.ui.pressToRecord') : t('dashboard.hotkeyNotSet')} - - {dictationBinding && } - - - - } - sx={{ height: 38, px: 2 }} - > - - {t('dashboard.keySetup')} - - - - - {/* 중: 수치 2종 */} - - {[ - { - label: t('dashboard.words'), - value: formatNumber(stats?.totalWordCount ?? 0), - unit: t('dashboard.total'), - }, - { - label: t('dashboard.sessionsToday'), - value: `${stats?.todaySessionCount ?? 0}`, - unit: t('dashboard.sessions'), - }, - ].map((item) => ( - - - {item.label} - - - - {item.value} - - {item.unit && ( - - {item.unit} - - )} - - - ))} - - - {/* 우: CTA 버튼 스택 */} - - statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })} - trailingIcon={} - > - - {t('dashboard.viewStats')} - - - - {t('dashboard.systemSettings')} - - - - - {/* 하단: 실시간 그라디언트 웨이브폼 */} - - - - - - {/* ── 2. 컬러 링 스탯 4타일 (Bento Grid) ───────────────────── */} - - {statTiles.map((tile) => ( - - - - {tile.icon} - - - - {tile.label} - - - - {tile.value} - - - {tile.unit} - - - - - - ))} - - - {/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */} - - {/* 백엔드 카드 */} - - } - right={ - - {premiumStatus?.backend === 'premium' - ? t('dashboard.backendPremium') - : ollamaConnected - ? t('dashboard.localConnected') - : t('service.offline')} - - } - /> - - - - - - - {premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.backendLocal')} - - - - {t('dashboard.modelLabel')}: {llmModel ?? '—'} - - { - window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide')) - }} - > - - {t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')} - - - - - - - - - {/* 요금제 카드 */} - - } /> - - - - - - - - {t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()} - - - {licenseTier === 'free' && {t('dashboard.planFree')}} - - - {[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => ( - - - - - - {feat} - - - ))} - - - - - - {/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */} - - {/* 사용량 카드 */} - - } /> - - {usageQuotas.length === 0 && ( - - — - - )} - {usageQuotas.map((q, idx) => ( - - - - {idx % 2 === 0 ? : } - - - {t(`license.feature.${q.feature}`)} - - - {q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`} - - - - - ))} - - - - {/* 실시간 자막 카드 */} - - } - right={ - - {captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')} - - } - /> - - - {t('dashboard.captionPlaceholder')} - - { - if (captionState === 'active') { - await window.electronAPI.caption.stop() - } else if (captionState === 'inactive') { - await window.electronAPI.caption.start() - } - }} - sx={{ alignSelf: 'center', px: 4 }} - > - - {captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')} - - - - - - {/* ── 5. 시스템 상태 및 오디오 스펙트럼 ──────────────────── */} - - } /> - - {/* 좌: 서비스 상태 리스트 */} - - {services.map((svc) => ( - { - if (!svc.ok && svc.name === t('service.ollamaLlm')) { - window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide')) - } - }} - > - - - {svc.name} - - - {svc.status} - - - ))} - - - {/* 우: 실시간 오디오 레벨 & 비주얼라이저 */} - - - - {t('service.audioInput')} Telemetry - - - {Math.round(audioLevel * 100)}% RMS - - - - - - - - {/* ── 5b. 입력 인사이트 (주간) ──────────────────── */} - {inputInsights.telemetry?.enabled ? ( - - } - right={ - - {t('input.insights.keystrokes')} {formatNumber(inputInsights.summary?.totals.keystrokes ?? 0)} - - } - /> - - {[ - { - label: t('input.insights.clicks'), - value: formatNumber(inputInsights.summary?.totals.clicks ?? 0), - }, - { - label: t('input.insights.words'), - value: formatNumber(inputInsights.summary?.totals.words ?? 0), - }, - { - label: t('input.insights.sentences'), - value: formatNumber(inputInsights.summary?.totals.sentences ?? 0), - }, - { - label: t('input.insights.mouseDistance'), - value: `${inputInsights.summary?.averages.mouseDistanceMeters ?? 0}${t('input.insights.perDayMeters')}`, - }, - { - label: t('input.insights.phrases'), - value: formatNumber(inputInsights.summary?.phraseCount ?? 0), - }, - ].map((item) => ( - - - {item.label} - - - {item.value} - - - ))} - - - ) : null} - - {/* ── 6. 파일 전사 드롭존 ──────────────────────────── */} - - - - - {/* ── 7. 최근 히스토리 목록 ───────────────────────── */} - - - {t('history.entries', { count: history.length })} - - } - /> - - {history.length === 0 ? ( - - ) : ( - - {groupedHistory.map((group) => ( - - - - {group.label} - - - - {t('history.count', { count: group.entries.length })} - - - - - {group.entries.map((entry) => ( - navigator.clipboard.writeText(text)} - onDelete={(id) => { - window.electronAPI.history.delete({ id }) - loadData() - }} - showTags - /> - ))} - - - ))} - - )} - - - ) -} diff --git a/apps/desktop/src/renderer/pages/DictionaryPage.tsx b/apps/desktop/src/renderer/pages/DictionaryPage.tsx index 1ea63cd..b79efd8 100644 --- a/apps/desktop/src/renderer/pages/DictionaryPage.tsx +++ b/apps/desktop/src/renderer/pages/DictionaryPage.tsx @@ -1,25 +1,56 @@ // src/renderer/pages/DictionaryPage.tsx -// Precision Vocabulary & Phonetic Dictionary Editor +// 사전 — 받아쓰기가 먼저 알아들을 단어 목록(2급 "도구"). +// +// 2026-09-28 개편: 카드 그리드 → 한 열 목록(단어 · 발음 · 분류) + 검색. 가져오기·내보내기 아이콘 4개 → 메뉴 두 개. +// 한 건 삭제는 "삭제했습니다 · 되돌리기". 목록을 읽지 못하면 조용히 비우지 않고 오류 + 다시 시도. +// 사용 횟수는 뺐다 — main 이 늘리는 곳이 없어 항상 0 이었다(근거 없는 수치). -import React, { useState, useEffect, useCallback, useRef } from 'react' -import { Box, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Tooltip } from '@mui/material' -import { Plus, Trash2, Pencil, BookA, Download, Upload } from 'lucide-react' -import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' -import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared' -import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' -import { useI18n } from '@d3ro/i18n' -import type { DictionaryEntry, DictionaryPage as DictPageData } from '@d3ro/core/types' +import React, { useState, useEffect, useCallback, useRef, useId } from 'react' import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Menu, + MenuItem, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import { BookA, ChevronDown, Pencil, Plus, Trash2 } from 'lucide-react' +import { PhysicalButton } from '@d3ro/ui/components/ds' +import { EmptyStateCard, PageHeader, SearchInput, useNotify } from '../components/shared' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { DictionaryEntry } from '@d3ro/core/types' +import { + DICTIONARY_LIMITS, dictionarySaveErrorKey, toDictionaryAddParams, toDictionaryUpdateParams, } from '@d3ro/core/dictionary-policy' +import { ToolLoadError, ToolLoading, ToolRow, TOOL_PAGE_SX, TOOL_TEXT_SX } from './tools/ToolSection' +import { useDeferredDelete } from './tools/useDeferredDelete' +import { dictionaryCategoryLabelKey, withoutHidden } from './tools/tools-model' + +const PAGE_SIZE = 50 + +type FileFormat = 'json' | 'csv' export function DictionaryPage(): React.ReactElement { const { t } = useI18n() - const [data, setData] = useState(null) + const notify = useNotify() + const { hidden, remove } = useDeferredDelete() + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(0) + const [totalPages, setTotalPages] = useState(0) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [loadError, setLoadError] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) const [editId, setEditId] = useState(null) const [formWord, setFormWord] = useState('') @@ -28,23 +59,69 @@ export function DictionaryPage(): React.ReactElement { const [formError, setFormError] = useState(null) const isSavingRef = useRef(false) const [ioBusy, setIoBusy] = useState(false) - const [ioMessage, setIoMessage] = useState(null) + /** 가장 최근 요청만 반영한다 — 검색어를 빨리 바꿀 때 늦게 온 이전 응답이 목록을 덮지 않게 */ + const requestRef = useRef(0) + + const fetchPage = useCallback( + (pageIndex: number) => { + const trimmed = search.trim() + return trimmed + ? window.electronAPI.dictionary.search({ query: trimmed, page: pageIndex, pageSize: PAGE_SIZE }) + : window.electronAPI.dictionary.getAll({ page: pageIndex, pageSize: PAGE_SIZE }) + }, + [search], + ) const loadData = useCallback(async () => { + const request = ++requestRef.current setLoading(true) - const trimmed = search.trim() - const result = trimmed - ? await window.electronAPI.dictionary.search({ query: trimmed, page: 0, pageSize: 50 }) - : await window.electronAPI.dictionary.getAll({ page: 0, pageSize: 50 }) - if (result.success) setData(result.data) - setLoading(false) - }, [search]) + try { + const result = await fetchPage(0) + if (request !== requestRef.current) return + if (result.success) { + setEntries(result.data.entries) + setTotal(result.data.total) + setPage(0) + setTotalPages(result.data.totalPages) + setLoadError(false) + } else { + setLoadError(true) + } + } catch { + if (request === requestRef.current) setLoadError(true) + } finally { + if (request === requestRef.current) setLoading(false) + } + }, [fetchPage]) + + const loadMore = async (): Promise => { + if (loadingMore) return + const request = requestRef.current + setLoadingMore(true) + try { + const result = await fetchPage(page + 1) + if (request !== requestRef.current) return + if (result.success) { + setEntries((prev) => { + const seen = new Set(prev.map((e) => e.id)) + return [...prev, ...result.data.entries.filter((e) => !seen.has(e.id))] + }) + setPage(result.data.page) + setTotal(result.data.total) + setTotalPages(result.data.totalPages) + } else { + notify({ message: t('tools.dictionary.loadFailed'), severity: 'error' }) + } + } finally { + setLoadingMore(false) + } + } useEffect(() => { - loadData() + void loadData() // 다른 기기(모바일·웹)에서 바뀐 사전이 반영되면 다시 불러온다. return window.electronAPI.app.onDataChanged((data) => { - if (data.type === 'cloud-sync' && data.entities?.includes('dictionary')) loadData() + if (data.type === 'cloud-sync' && data.entities?.includes('dictionary')) void loadData() }) }, [loadData]) @@ -82,235 +159,162 @@ export function DictionaryPage(): React.ReactElement { return } setDialogOpen(false) - loadData() + void loadData() } finally { setSaving(false) isSavingRef.current = false } } - const handleExport = async (format: 'json' | 'csv'): Promise => { + const handleDelete = (entry: DictionaryEntry) => { + remove({ + id: entry.id, + message: t('tools.dictionary.deleted', { word: entry.word }), + commit: async () => (await window.electronAPI.dictionary.delete({ id: entry.id })).success, + onCommitted: () => void loadData(), + }) + } + + const handleExport = async (format: FileFormat): Promise => { if (ioBusy) return setIoBusy(true) - setIoMessage(null) try { const result = await window.electronAPI.dictionary.export({ format }) - setIoMessage(result.success ? t('dictionary.exported') : t('dictionary.exportFailed')) + notify( + result.success + ? { message: t('dictionary.exported'), severity: 'success' } + : { message: t('dictionary.exportFailed'), severity: 'error' }, + ) } finally { setIoBusy(false) } } - const handleImport = async (format: 'json' | 'csv'): Promise => { + const handleImport = async (format: FileFormat): Promise => { if (ioBusy) return setIoBusy(true) - setIoMessage(null) try { const result = await window.electronAPI.dictionary.import({ filePath: '', format }) if (!result.success) { - setIoMessage(t('dictionary.importFailed')) + notify({ message: t('dictionary.importFailed'), severity: 'error' }) return } const { imported, skipped, errors } = result.data - setIoMessage( - t('dictionary.importedCount', { + notify({ + message: t('dictionary.importedCount', { imported: String(imported), skipped: String(skipped), errors: String(errors), - }) - ) + }), + severity: errors > 0 ? 'warning' : 'success', + }) await loadData() } finally { setIoBusy(false) } } - const ioButtonSx = { - p: 0.7, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, - } as const + const visible = withoutHidden(entries, hidden) + const hiddenCount = entries.length - visible.length + const shownTotal = Math.max(0, total - hiddenCount) + const hasMore = page + 1 < totalPages return ( - + - - void handleExport('json')} - sx={ioButtonSx} - > - - - - - void handleExport('csv')} - sx={ioButtonSx} - > - - - - - void handleImport('json')} - sx={ioButtonSx} - > - - - - - void handleImport('csv')} - sx={ioButtonSx} - > - - - - }> - {t('dictionary.add')} + <> + void handleImport(format)} + /> + void handleExport(format)} + /> + }> + {t('tools.dictionary.add')} - + } /> - {ioMessage && ( - - - {ioMessage} - - - )} + - + {loadError && void loadData()} />} - {loading ? ( - - {t('common.loading')} - - ) : !data || data.entries.length === 0 ? ( - } - action={ - - - {t('dictionary.add')} - - } - /> + {loading && entries.length === 0 ? ( + + ) : visible.length === 0 ? ( + loadError ? null : search.trim() ? ( + } /> + ) : ( + } + action={ + }> + {t('tools.dictionary.add')} + + } + /> + ) ) : ( - - {data.entries.map((entry: DictionaryEntry) => ( - - - - - - {entry.word} - - {entry.pronunciation && ( - - [{entry.pronunciation}] - - )} - - - - - {entry.category.toUpperCase()} - - - {t('dictionary.used', { count: entry.usageCount })} - - + + {visible.map((entry) => ( + + + + + {entry.word} + + {entry.pronunciation && ( + + {t('tools.dictionary.pronunciationValue', { value: entry.pronunciation })} + + )} - - + + {t(dictionaryCategoryLabelKey(entry.category))} + + - openEdit(entry)} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, - }} - > + openEdit(entry)}> - { - await window.electronAPI.dictionary.delete({ id: entry.id }) - loadData() - }} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg }, - }} - > + handleDelete(entry)}> - - + + ))} )} - {/* Add / Edit Dialog */} - setDialogOpen(false)} - maxWidth="xs" - fullWidth - PaperProps={{ - sx: { - bgcolor: d3roPalette.bg.card, - borderRadius: d3roRadius.doubleBezelOuter, - border: `1px solid ${d3roPalette.glass.hairlineStrong}`, - boxShadow: d3roShadow.dialog, - p: 1, - }, - }} - > - + {hasMore && visible.length > 0 && ( + + void loadMore()} disabled={loadingMore}> + {loadingMore ? t('common.loading') : t('tools.loadMore', { shown: visible.length, total: shownTotal })} + + + )} + + {/* 추가·편집 */} + setDialogOpen(false)} maxWidth="xs" fullWidth> + {editId ? t('dictionary.editTitle') : t('dictionary.addTitle')} - + setFormPronunciation(e.target.value)} fullWidth + slotProps={{ htmlInput: { maxLength: DICTIONARY_LIMITS.pronunciation } }} sx={{ mt: 2.5 }} - helperText="e.g. 디쓰리오" + helperText={t('tools.dictionary.pronunciationHelp')} /> {formError && ( - + {formError} - + )} - + setDialogOpen(false)}> {t('common.cancel')} - + void handleSave()} disabled={!formWord.trim() || saving}> {saving ? t('common.saving') : t('common.save')} @@ -349,3 +355,41 @@ export function DictionaryPage(): React.ReactElement { ) } + +/** "가져오기"·"내보내기" 메뉴 — 파일 형식 두 가지 */ +function FileMenu({ + label, + disabled, + onPick, +}: { + label: string + disabled: boolean + onPick: (format: FileFormat) => void +}): React.ReactElement { + const { t } = useI18n() + const menuId = useId() + const [anchor, setAnchor] = useState(null) + const pick = (format: FileFormat) => { + setAnchor(null) + onPick(format) + } + return ( + <> + } + onClick={(e) => setAnchor(e.currentTarget)} + > + {label} + + setAnchor(null)}> + pick('json')}>{t('tools.dictionary.formatJson')} + pick('csv')}>{t('tools.dictionary.formatCsv')} + + + ) +} diff --git a/apps/desktop/src/renderer/pages/HistoryPage.tsx b/apps/desktop/src/renderer/pages/HistoryPage.tsx index e2f3c56..cb7c536 100644 --- a/apps/desktop/src/renderer/pages/HistoryPage.tsx +++ b/apps/desktop/src/renderer/pages/HistoryPage.tsx @@ -1,265 +1,546 @@ // src/renderer/pages/HistoryPage.tsx -// High-End Agency Dictation History & Memory Timeline +// 기록 — 받아쓰기·자막·파일 전사 결과를 날짜별 한 열 목록으로 훑고, 찾고, 다시 쓰는 화면(2026-09-28 개편). +// +// - 날짜 묶음(오늘/어제/날짜) 아래 한 줄씩. 줄을 누르면 그 자리에서 펼쳐져 본문·요약·태그·행동 줄을 보인다. +// - 검색과 태그·모드·즐겨찾기 필터를 함께 쓴다. 서버는 조건 하나만 받으므로 나머지는 불러온 목록 위에서 거른다. +// - 목록 끝에서 다음 페이지를 이어 불러온다(자동 + "더 불러오기" 버튼). +// - 한 건 삭제는 즉시 숨기고 "삭제했습니다 · 되돌리기". 전체 삭제는 더보기 메뉴 → 확인 대화상자. -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Box, IconButton, Tooltip } from '@mui/material' -import { Download, Trash2, Clock, Filter } from 'lucide-react' -import { PhosphorText, TactileBadge, PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roTypo } from '@d3ro/ui/theme' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Box, IconButton, Menu, MenuItem, Skeleton, Tooltip, Typography } from '@mui/material' +import { AlertTriangle, Download, Mic, MoreHorizontal, SearchX, Trash2 } from 'lucide-react' +import { PhysicalButton } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' -import { getDateKey } from '../utils/formatters' -import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared' -import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@d3ro/core/types' -import { isClearAllDialogOpen, isHistoryViewFiltered } from './history/clear-all-history-flow' +import type { HistoryEntry, TagCount } from '@d3ro/core/types' +import { EmptyStateCard, PageHeader, HistoryEntryCard, useNotify } from '../components/shared' +import { BindingKeycaps } from '../components/keybinding/Keycap' +import { useKeyBindingMap } from '../hooks/useKeyBindingMap' +import { useRouteRequest } from '../navigation/app-navigation' +import { openSettings } from '../navigation/settingsTabs' +import { formatClockTime, formatDayLabel, getDateKey } from '../utils/formatters' +import { isClearAllDialogOpen } from './history/clear-all-history-flow' import { useClearAllHistory } from './history/useClearAllHistory' import { ClearAllHistoryDialog } from './history/ClearAllHistoryDialog' +import { RecordsFilterBar } from './history/RecordsFilterBar' +import { hasMoreRecords } from './history/records-list-store' +import { useRecordsList } from './history/useRecordsList' +import { + EMPTY_RECORDS_FILTER, + applyRecordsFilter, + groupRecordsByDay, + isRecordsFilterActive, + recordDayKind, + selectRecordsSource, + type RecordModeFilter, + type RecordsFilter, +} from './history/records-view' -const PAGE_SIZE = 50 +/** 검색어 입력 뒤 조회까지 기다리는 시간 */ +const SEARCH_DEBOUNCE_MS = 200 export function HistoryPage(): React.ReactElement { - const { t, formatRelativeDate } = useI18n() - const [data, setData] = useState(null) - const [search, setSearch] = useState('') - const [loading, setLoading] = useState(true) - const [allTags, setAllTags] = useState([]) - const [activeTag, setActiveTag] = useState(null) + const { t, locale } = useI18n() + const notify = useNotify() + const bindingMap = useKeyBindingMap() + const dictationBinding = bindingMap?.dictation[0] ?? null - const loadTags = useCallback(async () => { - const result = await window.electronAPI.memo.getAllTags() - if (result.success) setAllTags(result.data) - }, []) - - const loadData = useCallback(async () => { - setLoading(true) - let result: { success: boolean; data: HistoryPageData } | { success: false; error: unknown } - - if (activeTag) { - result = await window.electronAPI.memo.searchByTag({ tag: activeTag, page: 0, pageSize: PAGE_SIZE }) - } else if (search.trim()) { - result = await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE }) - } else { - result = await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' }) - } - if (result.success) setData(result.data) - setLoading(false) - }, [search, activeTag]) + // ── 필터 ──────────────────────────────────────────────── + const [queryInput, setQueryInput] = useState('') + const [query, setQuery] = useState('') + const [tag, setTag] = useState(null) + const [mode, setMode] = useState('all') + const [favoritesOnly, setFavoritesOnly] = useState(false) useEffect(() => { - loadData() - loadTags() - const unsub = window.electronAPI.app.onDataChanged(() => { - loadData() - loadTags() - }) - return unsub - }, [loadData, loadTags]) + if (queryInput === query) return undefined + const timer = setTimeout(() => setQuery(queryInput), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [queryInput, query]) - const groupedEntries = useMemo(() => { - if (!data) return [] - const groups: Record = {} - for (const entry of data.entries) { - const key = getDateKey(entry.createdAt) - if (!groups[key]) { - groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] } - } - groups[key].entries.push(entry) - } - return Object.values(groups) - }, [data, formatRelativeDate]) + const filter = useMemo(() => ({ query, tag, mode, favoritesOnly }), [query, tag, mode, favoritesOnly]) + const filterActive = isRecordsFilterActive(filter) - const handleCopy = useCallback((text: string) => { - navigator.clipboard.writeText(text) + const resetFilters = useCallback(() => { + setQueryInput(EMPTY_RECORDS_FILTER.query) + setQuery(EMPTY_RECORDS_FILTER.query) + setTag(EMPTY_RECORDS_FILTER.tag) + setMode(EMPTY_RECORDS_FILTER.mode) + setFavoritesOnly(EMPTY_RECORDS_FILTER.favoritesOnly) }, []) - const isExportingRef = useRef(false) - const isDeletingSingleRef = useRef(false) + // ── 목록 · 전체 건수 · 태그 ───────────────────────────── + const list = useRecordsList(selectRecordsSource(filter)) + const { state: listState, reload, loadMore, ensureRecord, patch, remove } = list + const [totalAll, setTotalAll] = useState(null) + const [allTags, setAllTags] = useState([]) - const handleDelete = useCallback( - async (id: string) => { - if (isDeletingSingleRef.current) return - isDeletingSingleRef.current = true - try { - await window.electronAPI.history.delete({ id }) - loadData() - } finally { - isDeletingSingleRef.current = false + const loadMeta = useCallback(async () => { + const [tagsResult, countResult] = await Promise.all([ + window.electronAPI.memo.getAllTags().catch(() => null), + window.electronAPI.history.getAll({ page: 0, pageSize: 1, sortOrder: 'desc' }).catch(() => null), + ]) + if (tagsResult?.success) setAllTags(tagsResult.data) + if (countResult?.success) setTotalAll(countResult.data.total) + }, []) + + useEffect(() => { + void loadMeta() + return window.electronAPI.app.onDataChanged(() => { + reload() + void loadMeta() + }) + }, [loadMeta, reload]) + + // ── 펼침 · 되돌리기를 기다리는 삭제 ───────────────────── + const [expandedId, setExpandedId] = useState(null) + const [hiddenIds, setHiddenIds] = useState>(() => new Set()) + + const visible = useMemo( + () => applyRecordsFilter(listState.entries, filter, hiddenIds), + [listState.entries, filter, hiddenIds], + ) + const groups = useMemo(() => groupRecordsByDay(visible, getDateKey), [visible]) + const hasMore = hasMoreRecords(listState) + + const dayLabel = useCallback( + (ts: number): string => { + switch (recordDayKind(ts, Date.now())) { + case 'today': + return t('date.today') + case 'yesterday': + return t('date.yesterday') + case 'thisYear': + return formatDayLabel(ts, locale) + case 'older': + return new Date(ts).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }) } }, - [loadData], + [t, locale], ) - const handleToggleFavorite = useCallback(async (entry: HistoryEntry) => { - const next = !entry.isFavorite - const apply = (value: boolean): void => - setData((prev) => - prev - ? { ...prev, entries: prev.entries.map((e) => (e.id === entry.id ? { ...e, isFavorite: value } : e)) } - : prev, - ) - apply(next) - const result = await window.electronAPI.history.setFavorite({ id: entry.id, isFavorite: next }) - if (!result.success) apply(entry.isFavorite) + const toggleExpanded = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) }, []) - const handleTagClick = useCallback((tag: string) => { - setActiveTag((prev) => (prev === tag ? null : tag)) - setSearch('') - }, []) + // ── 홈 등에서 넘긴 요청: 검색어로 시작 / 이 기록을 펼쳐 보이기 ── + const [scrollTargetId, setScrollTargetId] = useState(null) + const ensureTriedRef = useRef(null) - const handleExport = useCallback(async () => { - if (isExportingRef.current) return - isExportingRef.current = true - try { - await window.electronAPI.memo.export({ - format: 'markdown' as const, - tag: activeTag ?? undefined, - }) - } finally { - isExportingRef.current = false + useRouteRequest('records', (params) => { + if (params.query === undefined && params.recordId === undefined) return + resetFilters() + if (params.query) { + setQueryInput(params.query) + setQuery(params.query) } - }, [activeTag]) + if (params.recordId) { + ensureTriedRef.current = null + setExpandedId(params.recordId) + setScrollTargetId(params.recordId) + } + }) + + // 불러온 목록에 없으면(오래된 기록) 그 한 건을 가져와 끼워 넣는다 + useEffect(() => { + const id = scrollTargetId + if (!id || listState.status !== 'ready') return + if (listState.entries.some((e) => e.id === id)) return + if (ensureTriedRef.current === id) return + ensureTriedRef.current = id + void ensureRecord(id).then((found) => { + if (found) return + setScrollTargetId((cur) => (cur === id ? null : cur)) + setExpandedId((cur) => (cur === id ? null : cur)) + notify({ message: t('records.notFound'), severity: 'warning' }) + }) + }, [scrollTargetId, listState.status, listState.entries, ensureRecord, notify, t]) + + useEffect(() => { + if (!scrollTargetId) return + const row = document.getElementById(`record-${scrollTargetId}`) + if (!row) return + row.scrollIntoView({ block: 'start' }) + row.querySelector('[data-record-toggle]')?.focus({ preventScroll: true }) + setScrollTargetId(null) + }, [scrollTargetId, visible]) + + // ── 목록 끝에서 자동으로 이어 불러오기 (버튼은 대체 수단) ── + const sentinelRef = useRef(null) + const autoLoad = hasMore && !listState.loadingMore && !listState.loadMoreFailed && visible.length > 0 + useEffect(() => { + const node = sentinelRef.current + if (!autoLoad || !node || typeof IntersectionObserver === 'undefined') return undefined + const observer = new IntersectionObserver( + (items) => { + if (items.some((item) => item.isIntersecting)) loadMore() + }, + { rootMargin: '240px 0px' }, + ) + observer.observe(node) + return () => observer.disconnect() + }, [autoLoad, loadMore, listState.loadedPages]) + + // ── 행동 ──────────────────────────────────────────────── + const handleCopy = useCallback( + async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + notify({ message: t('records.action.copyFailed'), severity: 'error' }) + return false + } + }, + [notify, t], + ) + + const unhide = useCallback((id: string) => { + setHiddenIds((prev) => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + }, []) + + const commitDelete = useCallback( + async (id: string) => { + const result = await window.electronAPI.history.delete({ id }).catch(() => null) + if (result?.success) { + remove(id) + setTotalAll((n) => (n === null ? n : Math.max(0, n - 1))) + unhide(id) + return + } + unhide(id) + notify({ message: t('records.deleteFailed'), severity: 'error' }) + }, + [remove, unhide, notify, t], + ) + + // 확인 없이 숨기고, 알림이 사라질 때 실제로 지운다. "되돌리기"면 다시 보인다. + const handleDelete = useCallback( + (entry: HistoryEntry) => { + const id = entry.id + setHiddenIds((prev) => new Set(prev).add(id)) + setExpandedId((prev) => (prev === id ? null : prev)) + notify({ + message: t('common.deleted'), + action: { label: t('common.undo'), onClick: () => unhide(id) }, + onExpire: () => void commitDelete(id), + }) + }, + [notify, t, unhide, commitDelete], + ) + + const handleToggleFavorite = useCallback( + async (entry: HistoryEntry) => { + const next = !entry.isFavorite + patch(entry.id, { isFavorite: next }) + const result = await window.electronAPI.history + .setFavorite({ id: entry.id, isFavorite: next }) + .catch(() => null) + if (!result?.success) { + patch(entry.id, { isFavorite: entry.isFavorite }) + notify({ message: t('records.favoriteFailed'), severity: 'error' }) + } + }, + [patch, notify, t], + ) + + const handleTagToggle = useCallback((next: string) => { + setTag((prev) => (prev === next ? null : next)) + }, []) + + const exportingRef = useRef(false) + const [exporting, setExporting] = useState(false) + const handleExport = useCallback(async () => { + if (exportingRef.current) return + exportingRef.current = true + setExporting(true) + try { + const result = await window.electronAPI.memo + .export({ format: 'markdown', tag: tag ?? undefined }) + .catch(() => null) + if (result?.success) notify({ message: t('records.exportDone', { path: result.data }), severity: 'success' }) + else notify({ message: t('records.exportFailed'), severity: 'error' }) + } finally { + exportingRef.current = false + setExporting(false) + } + }, [tag, notify, t]) // 전체 삭제는 필터와 무관하고 동기화로 다른 기기에도 전파되므로 반드시 확인 단계를 거친다 - const clearAll = useClearAllHistory(loadData) + const clearAll = useClearAllHistory((deleted) => { + reload() + void loadMeta() + notify( + deleted + ? { message: t('records.clearAllDone'), severity: 'success' } + : { message: t('records.clearAllFailed'), severity: 'error' }, + ) + }) const clearAllState = clearAll.state + const [menuAnchor, setMenuAnchor] = useState(null) + const closeMenu = useCallback(() => setMenuAnchor(null), []) - return ( - - {/* Page Header */} - - - - + // 줄 사이를 ↑/↓ 로 옮겨 다닌다 + const handleListKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return + const target = e.target as HTMLElement + if (!target.hasAttribute('data-record-toggle')) return + const toggles = Array.from(e.currentTarget.querySelectorAll('[data-record-toggle]')) + const index = toggles.indexOf(target) + const next = toggles[e.key === 'ArrowDown' ? index + 1 : index - 1] + if (!next) return + e.preventDefault() + next.focus() + }, []) + + // ── 화면 상태 ─────────────────────────────────────────── + // 전체 건수 조회가 실패했으면 필터 없는 목록의 건수로 판단한다 + const noRecordsAtAll = + !filterActive && listState.status === 'ready' && listState.entries.length === 0 && (totalAll ?? listState.total) === 0 + + const header = ( + + + + void handleExport()} disabled={exporting}> + {t('memo.export')} - + + + + setMenuAnchor(e.currentTarget)} + sx={{ color: d3roPalette.text.secondary }} + > + + + + {/* 메뉴 안의 항목을 누르면 메뉴는 닫힌다(onClick 이 위로 올라온다) */} + + + + {t('records.clearAllAction')} + + + + } + /> + ) - - - - - + let body: React.ReactNode + if (listState.status === 'loading') { + body = ( + + {Array.from({ length: 6 }, (_, i) => ( + + + + + ))} + + ) + } else if (listState.status === 'error') { + body = ( + } + message={t('records.loadError.title')} + description={t('records.loadError.description')} + action={ + + {t('common.retry')} + + } + /> + ) + } else if (noRecordsAtAll) { + body = ( + } + message={t('records.empty.title')} + description={t('records.empty.description')} + action={ + dictationBinding ? ( + + + {t('records.empty.shortcut')} + + + + ) : ( + + + {t('records.empty.unbound')} + + openSettings('general')}> + {t('records.empty.setShortcut')} + + + ) + } + /> + ) + } else if (visible.length === 0) { + // 불러온 범위에서 못 찾았지만 더 오래된 기록이 남아 있으면 이어서 찾게 한다 + body = ( + } + message={t('records.noResults.title')} + description={ + hasMore + ? t('records.noResults.partial', { count: listState.entries.length }) + : t('records.noResults.description') + } + action={ + + {hasMore && ( + + {listState.loadingMore ? t('records.loadingMore') : t('records.searchOlder')} + + )} + {filterActive && ( + + {t('records.filter.clear')} + + )} } /> + ) + } else { + body = ( + + {groups.map((group) => { + const headingId = `records-day-${group.key}` + return ( + + + + {dayLabel(group.ts)} + + + {t('history.count', { count: group.entries.length })} + + + + {group.entries.map((entry) => ( + void handleToggleFavorite(e)} + onTagClick={handleTagToggle} + activeTag={tag} + onTagsChanged={() => void loadMeta()} + /> + ))} + + + ) + })} + + {/* 목록 끝 — 자동으로 이어 불러오고, 버튼은 대체 수단 */} + {(hasMore || listState.loadMoreFailed) && ( + + {listState.loadMoreFailed && ( + + {t('records.loadMoreError')} + + )} + + {listState.loadingMore + ? t('records.loadingMore') + : listState.loadMoreFailed + ? t('common.retry') + : t('records.loadMore')} + + + {t('records.shown', { shown: listState.entries.length, total: listState.total })} + + + )} + + ) + } + + return ( + + {header} - {/* Search Input Bar */} - {!activeTag && ( - setFavoritesOnly((v) => !v)} + onTagToggle={handleTagToggle} + onClear={resetFilters} /> )} - {/* Tag Filters Bar */} - {allTags.length > 0 && ( - - - - {t('memo.tags') || 'TAGS'}: - - {allTags.map((tc) => { - const isSelected = activeTag === tc.tag - return ( - handleTagClick(tc.tag)} - sx={{ cursor: 'pointer' }} - > - #{tc.tag} ({tc.count}) - - ) - })} - {activeTag && ( - setActiveTag(null)} - sx={{ cursor: 'pointer' }} - > - {t('memo.clearFilter')} ✕ - - )} - - )} - - {loading ? ( - - {t('common.loading')} - - ) : !data || data.entries.length === 0 ? ( - } - /> - ) : ( - groupedEntries.map((group) => ( - - {/* Date Group Header */} - - - {group.label} - - - - {t('history.count', { count: group.entries.length })} - - - - {/* Entries Grid */} - - {group.entries.map((entry: HistoryEntry) => ( - - ))} - - - )) - )} + {body} ) } diff --git a/apps/desktop/src/renderer/pages/HomePage.tsx b/apps/desktop/src/renderer/pages/HomePage.tsx new file mode 100644 index 0000000..877d2ba --- /dev/null +++ b/apps/desktop/src/renderer/pages/HomePage.tsx @@ -0,0 +1,417 @@ +// src/renderer/pages/HomePage.tsx +// 홈("오늘") — 2026-09-28 정보 구조 개편. 대시보드를 대신한다. +// +// 이 화면이 답하는 질문은 셋뿐이다: 지금 무엇을 시작할 수 있나(받아쓰기 키·새 회의·자막), +// 방금 무엇을 했나(최근 기록·최근 회의), 막힌 것이 있나(조회된 상태만 — 조회하지 않은 상태를 '정상'이라 쓰지 않는다). +// 통계는 한 줄. 카드·링·글로우 없이 제목과 1px 선으로 구획한다. + +import React, { useCallback, useEffect, useState } from 'react' +import { Alert, Box, Button, ButtonBase, Tooltip, Typography } from '@mui/material' +import { Captions, CaptionsOff, Users } from 'lucide-react' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { d3roPalette, d3roRadius, d3roFocusRing, d3roTransition, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { + CaptionState, + HistoryEntry, + LicenseTier, + MeetingSessionSummary, + StatsSummary, + UsageQuota, +} from '@d3ro/core/types' +import { BindingKeycaps } from '../components/keybinding/Keycap' +import { useKeyBindingMap } from '../hooks/useKeyBindingMap' +import { FileDropZone } from '../components/FileDropZone' +import { useAppNavigation } from '../navigation/app-navigation' +import { openSettings } from '../navigation/settingsTabs' +import { featureLabelKey, recordModeLabelKey } from '../utils/display-labels' +import { formatClockTime, formatDayLabel, formatDurationLong, getDateKey } from '../utils/formatters' + +const RECENT_RECORDS = 6 +const RECENT_MEETINGS = 4 + +/** 제목 + 오른쪽 링크. 카드 대신 선 하나로 구획한다. */ +function Section({ + title, + action, + children, +}: { + title: string + action?: React.ReactNode + children: React.ReactNode +}): React.ReactElement { + const id = React.useId() + return ( + + + + {title} + + {action} + + {children} + + ) +} + +function LinkButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }): React.ReactElement { + return ( + + {children} + + ) +} + +/** 목록 한 줄 — 전체가 하나의 버튼 */ +function Row({ onClick, children, label }: { onClick: () => void; children: React.ReactNode; label: string }): React.ReactElement { + return ( + + {children} + + ) +} + +function EmptyLine({ children }: { children: React.ReactNode }): React.ReactElement { + return ( + {children} + ) +} + +export function HomePage(): React.ReactElement { + const { t, locale, formatRelativeDate } = useI18n() + const { navigate } = useAppNavigation() + const bindingMap = useKeyBindingMap() + const dictationBinding = bindingMap?.dictation[0] ?? null + + const [stats, setStats] = useState(null) + const [records, setRecords] = useState(null) + const [meetings, setMeetings] = useState(null) + /** null = 아직 조회 전. 조회한 상태만 보인다. */ + const [llmConnected, setLlmConnected] = useState(null) + const [captionState, setCaptionState] = useState('inactive') + const [meetingRecording, setMeetingRecording] = useState(false) + const [licenseTier, setLicenseTier] = useState('free') + const [usageQuotas, setUsageQuotas] = useState([]) + + const loadData = useCallback(() => { + window.electronAPI.stats.getSummary().then((r) => { + if (r.success) setStats(r.data) + }) + window.electronAPI.history.getAll({ page: 0, pageSize: RECENT_RECORDS, sortOrder: 'desc' }).then((r) => { + setRecords(r.success ? r.data.entries : []) + }) + window.electronAPI.meetingMode.getSessions({ page: 1, pageSize: RECENT_MEETINGS }).then((r) => { + setMeetings(r.success ? r.data.sessions : []) + }) + window.electronAPI.llm.getStatus().then((r) => { + if (r.success) setLlmConnected(r.data.connectionState === 'connected') + }) + window.electronAPI.caption.getState().then((r) => { + if (r.success) setCaptionState(r.data) + }) + window.electronAPI.meetingMode.getState().then((r) => { + if (r.success) setMeetingRecording(r.data.state === 'recording') + }) + window.electronAPI.license.getInfo().then((r) => { + if (r.success) setLicenseTier(r.data.tier) + }) + window.electronAPI.license.getAllUsage().then((r) => { + if (r.success) setUsageQuotas(r.data) + }) + }, []) + + useEffect(() => { + loadData() + const unsubData = window.electronAPI.app.onDataChanged(() => loadData()) + const unsubLlm = window.electronAPI.llm.onStatusChanged((e) => setLlmConnected(e.status.connectionState === 'connected')) + const unsubCaption = window.electronAPI.caption.onStateChanged((e) => setCaptionState(e.state)) + // 회의 녹음 중의 자막은 회의가 소유한다 — 홈에서 켜고 끌 수 없다. + const unsubMeeting = window.electronAPI.meetingMode.onStateChanged((e) => { + setMeetingRecording(e.state === 'recording') + loadData() + }) + return () => { + unsubData() + unsubLlm() + unsubCaption() + unsubMeeting() + } + }, [loadData]) + + const toggleCaption = useCallback(async () => { + if (meetingRecording) return + if (captionState === 'active') await window.electronAPI.caption.stop() + else if (captionState === 'inactive') await window.electronAPI.caption.start() + }, [captionState, meetingRecording]) + + const captionBusy = captionState !== 'active' && captionState !== 'inactive' + const captionLabel = + captionState === 'active' + ? t('home.action.captionStop') + : captionBusy + ? t('home.action.captionStarting') + : t('home.action.captionStart') + + const limitedQuotas = usageQuotas.filter((q) => q.limit !== -1) + const now = Date.now() + + return ( + + {/* ── 머리: 제목 · 날짜 · 받아쓰기 키 ───────────────── */} + + + + {t('home.title')} + + + {formatDayLabel(now, locale)} + + + + {dictationBinding ? ( + <> + + {t('home.dictationHint')} + + ) : ( + <> + {t('home.dictationUnbound')} + openSettings('general')}>{t('home.setShortcut')} + + )} + + + + {/* ── 시작할 수 있는 것 ──────────────────────────────── */} + + navigate('meetings', { startMeeting: true })}> + + {t('home.action.newMeeting')} + + + {/* 비활성 버튼에도 툴팁이 뜨도록 span 으로 감싼다 */} + + void toggleCaption()} + > + {captionState === 'active' ? : } + {captionLabel} + + + + + + {/* ── 막힌 것 — 조회된 상태만 ─────────────────────────── */} + {llmConnected === false && ( + window.dispatchEvent(new Event('d3ro:open-ollama-guide'))}> + {t('home.status.openGuide')} + + } + > + {t('home.status.llmOffline')} + + )} + + {/* ── 한 줄 요약 ─────────────────────────────────────── */} + {stats && ( + span + span::before': { content: '"·"', color: d3roPalette.text.tertiary, mr: 2 }, + }} + > + {t('home.summary.sessions', { count: stats.todaySessionCount.toLocaleString(locale) })} + {t('home.summary.words', { count: stats.todayWordCount.toLocaleString(locale) })} + {t('home.summary.minutes', { time: formatDurationLong(stats.todayRecordingTimeMs, locale) })} + {stats.streakDays > 1 && {t('home.summary.streak', { count: stats.streakDays })}} + + )} + + {/* ── 최근 기록 · 최근 회의 ─────────────────────────── */} + +
    navigate('records')}>{t('home.viewAll')}} + > + {records === null ? null : records.length === 0 ? ( + {t('home.emptyRecords')} + ) : ( + records.map((entry) => { + const text = entry.title ?? entry.polishedText ?? entry.originalText + return ( + navigate('records', { recordId: entry.id })}> + + {getDateKey(entry.createdAt) === getDateKey(now) + ? formatClockTime(entry.createdAt, locale) + : formatRelativeDate(entry.createdAt)} + + + {text} + + {entry.mode !== 'dictation' && ( + + {t(recordModeLabelKey(entry.mode))} + + )} + + ) + }) + )} +
    + +
    navigate('meetings')}>{t('home.viewAll')}} + > + {meetings === null ? null : meetings.length === 0 ? ( + {t('home.emptyMeetings')} + ) : ( + meetings.map((m) => { + const title = m.title ?? t('meeting.untitled') + return ( + navigate('meetings', { meetingSessionId: m.id })}> + + + {title} + + + {formatRelativeDate(m.startedAt)} + {m.durationMs !== null && ` · ${formatDurationLong(m.durationMs, locale)}`} + + + {m.status === 'recording' && {t('meeting.recording')}} + {m.status === 'processing' && {t('home.meetingInProgress')}} + {m.status === 'error' && {t('meeting.error')}} + + ) + }) + )} +
    +
    + + {/* ── 파일 전사 ─────────────────────────────────────── */} +
    + + + +
    + + {/* ── 사용량 — 한도가 있는 기능만 ─────────────────────── */} + {licenseTier === 'free' && limitedQuotas.length > 0 && ( +
    + + {limitedQuotas.map((q) => { + const ratio = Math.min(1, q.used / Math.max(1, q.limit)) + return ( + + + {t(featureLabelKey(q.feature))} + + + {q.used} / {q.limit} + + + + + + ) + })} + +
    + )} +
    + ) +} + diff --git a/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx b/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx index 7cb5f50..9966661 100644 --- a/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx +++ b/apps/desktop/src/renderer/pages/KnowledgeBasePage.tsx @@ -1,17 +1,23 @@ // src/renderer/pages/KnowledgeBasePage.tsx -// Local RAG Knowledge Base & Semantic Memory Intelligence +// 지식 베이스 — 내 문서에 묻고(로컬 RAG), 자동 수집된 입력 통계를 본다(2급 "도구"). +// +// 2026-09-28 개편: 질문 입력이 먼저, 문서 목록은 아래 한 열. 문서 목록 로딩·오류 표시, 문서 삭제는 "삭제했습니다 · 되돌리기". +// 예시 질문 칩이 setTimeout 으로 이전 query 를 잡아 눌러도 질문되지 않던 버그를 고쳤다(질문할 문장을 인자로 넘긴다). import React, { useState, useEffect, useCallback, useRef } from 'react' -import { Box, TextField, IconButton, Tooltip, LinearProgress, Tab, Tabs } from '@mui/material' -import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles } from 'lucide-react' -import { MetalCard, PhosphorText, Led, PhysicalButton, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds' -import { PageHeader, EmptyStateCard } from '../components/shared' +import { Box, IconButton, LinearProgress, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material' +import { FileText, Plus, RefreshCw, Trash2, Database } from 'lucide-react' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { EmptyStateCard, PageHeader } from '../components/shared' import { InputInsightsView } from '../components/input-insights/InputInsightsView' import { isImeComposingEvent } from '../utils/keyboard' -import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types' import { ErrorCode } from '@d3ro/core/errors' +import { ToolLoadError, ToolLoading, ToolRow, ToolSection, TOOL_PAGE_SX, TOOL_TABS_SX, TOOL_TEXT_SX } from './tools/ToolSection' +import { useDeferredDelete } from './tools/useDeferredDelete' +import { documentIndexLabelKey, KNOWLEDGE_EXAMPLE_KEYS, withoutHidden } from './tools/tools-model' type KnowledgeView = 'kb' | 'insights' @@ -19,8 +25,10 @@ export function KnowledgeBasePage(): React.ReactElement { // 이 화면은 두 축을 가진다: 수집된 지식 문서(RAG)와 자동 수집된 입력 데이터 통계. const [view, setView] = useState('kb') const { t } = useI18n() + const { hidden, remove } = useDeferredDelete() const [documents, setDocuments] = useState([]) const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(false) const [query, setQuery] = useState('') const [querying, setQuerying] = useState(false) const [result, setResult] = useState(null) @@ -31,20 +39,26 @@ export function KnowledgeBasePage(): React.ReactElement { const loadDocuments = useCallback(async () => { setLoading(true) - const resp = await window.electronAPI.rag.getDocuments() - if (resp.success) setDocuments(resp.data) - setLoading(false) + try { + const resp = await window.electronAPI.rag.getDocuments() + if (resp.success) setDocuments(resp.data) + setLoadError(!resp.success) + } catch { + setLoadError(true) + } finally { + setLoading(false) + } }, []) useEffect(() => { - loadDocuments() + void loadDocuments() }, [loadDocuments]) useEffect(() => { const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => { setIndexProgress(data) if (data.currentChunk <= 1) { - loadDocuments() + void loadDocuments() } }) // 색인 실행이 끝나면(성공·실패 무관) 진행 표시를 내리고, 문서가 끝내 색인되지 않았으면 알린다. @@ -73,11 +87,9 @@ export function KnowledgeBasePage(): React.ReactElement { try { const resp = await window.electronAPI.rag.addDocument() if (resp.success) { - loadDocuments() - } else { - if (!resp.error.message.includes('cancelled') && !resp.error.message.includes('already active')) { - setAddError(resp.error.message) - } + void loadDocuments() + } else if (!resp.error.message.includes('cancelled') && !resp.error.message.includes('already active')) { + setAddError(resp.error.message) } } finally { setAdding(false) @@ -85,13 +97,14 @@ export function KnowledgeBasePage(): React.ReactElement { } }, [adding, loadDocuments]) - const handleRemoveDocument = useCallback( - async (docId: string) => { - await window.electronAPI.rag.removeDocument({ documentId: docId }) - loadDocuments() - }, - [loadDocuments], - ) + const handleRemoveDocument = (doc: RAGDocument) => { + remove({ + id: doc.id, + message: t('tools.knowledge.deleted', { name: doc.fileName }), + commit: async () => (await window.electronAPI.rag.removeDocument({ documentId: doc.id })).success, + onCommitted: () => void loadDocuments(), + }) + } const handleReindex = useCallback(async (docId: string) => { setAddError(null) @@ -100,66 +113,62 @@ export function KnowledgeBasePage(): React.ReactElement { if (!resp.success && resp.error.code !== ErrorCode.RAGEmbeddingFailed) setAddError(resp.error.message) }, []) - const handleQuery = useCallback(async () => { - if (!query.trim()) return - setQuerying(true) - setResult(null) - setQueryError(null) - const resp = await window.electronAPI.rag.query({ query: query.trim() }) - if (resp.success) { - setResult(resp.data) - } else { - setQueryError(resp.error?.message || '검색 중 오류가 발생했습니다.') - } - setQuerying(false) - }, [query]) - - const handleQueryKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (isImeComposingEvent(e)) return - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleQuery() + /** 질문할 문장을 인자로 받는다 — 예시 칩이 setQuery 직후 부를 때 이전 query 를 잡지 않게 */ + const runQuery = useCallback( + async (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + setQuerying(true) + setResult(null) + setQueryError(null) + try { + const resp = await window.electronAPI.rag.query({ query: trimmed }) + if (resp.success) setResult(resp.data) + else setQueryError(resp.error?.message || t('tools.knowledge.queryFailed')) + } catch { + setQueryError(t('tools.knowledge.queryFailed')) + } finally { + setQuerying(false) } }, - [handleQuery], + [t], ) + const handleQueryKeyDown = (e: React.KeyboardEvent) => { + if (isImeComposingEvent(e)) return + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void runQuery(query) + } + } + + const visibleDocs = withoutHidden(documents, hidden) + return ( - + } - > - {adding ? t('rag.adding') : t('rag.addDocument')} - + view === 'kb' ? ( + void handleAddDocument()} + disabled={adding} + startIcon={} + > + {adding ? t('rag.adding') : t('rag.addDocument')} + + ) : undefined } /> setView(next)} - sx={{ - mb: 2.5, - minHeight: 38, - borderBottom: `1px solid ${d3roPalette.border.subtle}`, - '& .MuiTab-root': { - minHeight: 38, - py: 0.5, - textTransform: 'none', - color: d3roPalette.text.inactive, - fontSize: d3roTypo.label.size, - '&.Mui-selected': { color: d3roPalette.accent.main } - }, - '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 } - }} + aria-label={t('nav.knowledge')} + sx={{ ...TOOL_TABS_SX, mb: 3 }} > @@ -168,291 +177,211 @@ export function KnowledgeBasePage(): React.ReactElement { {view === 'insights' ? ( ) : ( - <> - {/* Indexing Progress Card */} - {indexProgress && ( - - - - - - {t('rag.indexing')}: {indexProgress.fileName} - - - - {indexProgress.currentChunk} / {indexProgress.totalChunks} Chunks ({indexProgress.percent}%) - - - - - )} - - {/* Error Card */} - {addError && ( - - - - - {addError} - - - - )} - - {/* Asking / Semantic Query Bar */} - - - - setQuery(e.target.value)} - onKeyDown={handleQueryKeyDown} - placeholder={t('rag.queryPlaceholder')} - size="small" - fullWidth - sx={{ - '& .MuiOutlinedInput-root': { - fontFamily: d3roFontSans, - fontSize: d3roTypo.compact.size, - bgcolor: d3roPalette.bg.inset, - borderRadius: d3roRadius.inner, - border: `1px solid ${d3roPalette.glass.hairline}`, - '& fieldset': { border: 'none' }, - '&:hover': { borderColor: d3roPalette.glass.hairlineStrong }, - '&.Mui-focused': { borderColor: d3roPalette.accent.main }, - }, - '& .MuiOutlinedInput-input': { color: d3roPalette.text.primary, py: 1.25 }, - }} - /> - - - {querying ? t('common.loading') : 'Search'} - - - - {/* Quick Suggestion Prompt Chips */} - {documents.length > 0 && !result && !querying && ( - - - PROMPTS: - - {[ - '이 문서들의 핵심 내용 3줄 요약해줘', - '주요 일정 및 마일스톤 정리해줘', - '보안 및 개인정보 관련 정책은?', - ].map((sample) => ( - { - setQuery(sample) - setTimeout(handleQuery, 50) - }} - sx={{ cursor: 'pointer' }} + + {/* ── 질문 ── */} + + + setQuery(e.target.value)} + onKeyDown={handleQueryKeyDown} + placeholder={visibleDocs.length > 0 ? t('rag.queryPlaceholder') : t('tools.knowledge.queryNeedsDocs')} + slotProps={{ htmlInput: { 'aria-label': t('rag.askQuestion') } }} + disabled={querying} + size="small" + fullWidth + /> + void runQuery(query)} + disabled={!query.trim() || querying} + sx={{ flexShrink: 0 }} > - {sample} - - ))} - - )} - - {/* Answer Result Display */} - {querying && ( - - - - {t('rag.searching')} - - - )} - - {queryError && ( - - - - {queryError} - - - )} - - {result && ( - - - - - Semantic Synthesis - + {querying ? t('rag.searching') : t('tools.knowledge.ask')} + - - {result.answer} - + {/* 예시 질문 — 누르면 그 문장으로 바로 묻는다 */} + {visibleDocs.length > 0 && !result && !querying && ( + + {t('tools.knowledge.examples')} + {KNOWLEDGE_EXAMPLE_KEYS.map((key) => { + const sample = t(key) + return ( + { + setQuery(sample) + void runQuery(sample) + }} + > + {sample} + + ) + })} + + )} - {/* Sources Attribution */} - {result.results.length > 0 && ( - - - {t('rag.sources').toUpperCase()} ({result.results.length}) - - - {result.results.slice(0, 3).map((r, i) => ( - - - - [{i + 1}] {r.fileName} - - - {Math.round(r.similarity * 100)}% - - - - {r.content.slice(0, 120)}... - - - ))} - + {querying && ( + + {t('rag.searching')} + + )} + + {queryError && ( + + {queryError} + + )} + + {result && ( + + + {t('tools.knowledge.answer')} + + + {result.answer} + + + {result.results.length > 0 && ( + + + {t('rag.sources')} {result.results.length} + + + {result.results.map((r, i) => ( + + + + + {i + 1} + + {r.fileName} + + + {t('tools.knowledge.similarity', { percent: Math.round(r.similarity * 100) })} + + + + {r.content.length > 160 ? `${r.content.slice(0, 160)}…` : r.content} + + + ))} + + + )} )} - )} - - {/* Document Library Section */} - - - - {t('rag.documents').toUpperCase()} ({documents.length}) - - - - {documents.length === 0 && !loading ? ( - } - action={ - - - {t('rag.addDocument')} - - } - /> - ) : ( - - {documents.map((doc) => ( - - - - - - - - - {doc.fileName} - - - - - {doc.fileType.toUpperCase()} - - - {doc.chunkCount} {t('rag.chunks')} - - - {doc.indexed ? t('rag.indexed') : t('rag.pending')} - - - - - - - handleReindex(doc.id)} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, - }} - > - - - - - handleRemoveDocument(doc.id)} - sx={{ - p: 0.6, - color: d3roPalette.text.inactive, - bgcolor: d3roPalette.glass.raised, - border: `1px solid ${d3roPalette.glass.hairline}`, - '&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg }, - }} - > - - - - + {/* ── 문서 ── */} + + {indexProgress && ( + + + + {t('rag.indexing')}: {indexProgress.fileName} + + + {t('tools.knowledge.chunkProgress', { + current: indexProgress.currentChunk, + total: indexProgress.totalChunks, + percent: indexProgress.percent, + })} + - - ))} - - )} - - + + + )} + + {addError && ( + + {addError} + + )} + + {loadError && void loadDocuments()} />} + + {loading && documents.length === 0 ? ( + + ) : visibleDocs.length === 0 ? ( + loadError ? null : ( + } + action={ + void handleAddDocument()} startIcon={}> + {t('rag.addDocument')} + + } + /> + ) + ) : ( + + {visibleDocs.map((doc) => { + const statusKey = documentIndexLabelKey(doc, indexProgress?.documentId ?? null) + return ( + + + + + + {doc.fileName} + + + {doc.fileType.toUpperCase()} · {t('tools.knowledge.chunkCount', { count: doc.chunkCount })} ·{' '} + + {t(statusKey)} + + + + + + void handleReindex(doc.id)} + > + + + + + handleRemoveDocument(doc)} + > + + + + + + + ) + })} + + )} + + )}
    ) diff --git a/apps/desktop/src/renderer/pages/MeetingModePage.tsx b/apps/desktop/src/renderer/pages/MeetingModePage.tsx index 06eacd8..69f7d2a 100644 --- a/apps/desktop/src/renderer/pages/MeetingModePage.tsx +++ b/apps/desktop/src/renderer/pages/MeetingModePage.tsx @@ -1,55 +1,30 @@ // src/renderer/pages/MeetingModePage.tsx -// High-End Meeting Intelligence Studio: Live Transcribing & On-the-fly Editing + Context Enricher Memos +// 회의 — 목록(한 열) · 녹음 중(전사 + 회의 메모 두 열) · 상세(요약 먼저, 전사는 근거). +// 이 파일은 세 뷰 사이의 상태 기계와 main 이벤트 구독을 맡는다. 목록은 meeting/MeetingListView, +// 상세는 components/meeting/MeetingDetailTabs, 오류는 meeting/MeetingErrorDialog 가 그린다. +// 회의 녹음 중 자막은 회의가 소유한다(홈의 자막 버튼은 잠긴다) — 여기서 자막을 따로 켜거나 끄지 않는다. import React, { useState, useEffect, useCallback, useRef } from 'react' -import { - Box, - LinearProgress, - TextField, - Snackbar, - Alert, - Typography, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Divider, - CircularProgress, -} from '@mui/material' -import { - Plus, - Square, - Circle, - Users, - Clock, - FileText, - Sparkles, - Mic, - Tag, - CheckCircle2, - ListTodo, - Lightbulb, - AlertTriangle, - AlertCircle, - Settings, - RefreshCw, - Cpu, -} from 'lucide-react' -import { - MetalCard, - PhosphorText, - PhysicalButton, - ScreenPanel, - DoubleBezelCard, - TactileBadge, - AudioVisualizerBar, -} from '@d3ro/ui/components/ds' +import { Box, LinearProgress, TextField, Typography } from '@mui/material' +import { AlertTriangle, CheckCircle2, Lightbulb, ListTodo, Square, Tag } from 'lucide-react' +import { AudioVisualizerBar, Led, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { d3roFontMono, d3roFontSans, d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { TranslationKey } from '@d3ro/i18n' +import type { + CaptionSegment, + MeetingMemo, + MeetingModeStateInfo, + MeetingProcessingProgress, + MeetingProcessingStep, + MeetingSessionDetail, +} from '@d3ro/core/types' import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs' import { EditableSegment } from '../components/meeting/EditableSegment' -import { PageHeader, EmptyStateCard } from '../components/shared' +import { formatElapsed } from '../components/meeting/meeting-format' +import { PageHeader, useNotify } from '../components/shared' import { isImeComposingEvent } from '../utils/keyboard' -import { openSettings } from '../navigation/settingsTabs' +import { useRouteRequest } from '../navigation/app-navigation' import { isActiveMeetingState, recordingStartFromElapsed, @@ -57,51 +32,49 @@ import { rollbackSegmentText, } from './meeting/live-session' import { mergeRestoredItems } from './meeting/live-restore' -import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme' -import { useI18n } from '@d3ro/i18n' -import type { - MeetingSessionSummary, - MeetingSessionDetail, - MeetingModeStateInfo, - MeetingProcessingProgress, - CaptionSegment, - MeetingMemo, -} from '@d3ro/core/types' +import { buildMeetingErrorInfo } from './meeting/meeting-error' +import type { MeetingErrorInfo } from './meeting/meeting-error' +import { MeetingErrorDialog } from './meeting/MeetingErrorDialog' +import { MeetingListView } from './meeting/MeetingListView' type MeetingView = 'list' | 'recording' | 'detail' -interface MeetingErrorInfo { - type: 'collision' | 'stt' | 'audio' | 'general' - title: string - code?: number | string - message: string - reason: string - suggestion: string -} - -const CONTEXT_TAGS = [ - { label: '#결정', icon: , color: d3roPalette.tag.green }, - { label: '#할일', icon: , color: d3roPalette.accent.light }, - { label: '#안건', icon: , color: d3roPalette.tag.orange }, - { label: '#아이디어', icon: , color: d3roPalette.tag.purple }, - { label: '#이슈', icon: , color: d3roPalette.tag.red }, +/** 녹음 중 메모 앞에 붙이는 빠른 태그. 색은 아이콘(점) 전용 카테고리색이다. */ +const CONTEXT_TAGS: ReadonlyArray<{ key: TranslationKey; icon: React.ReactNode }> = [ + { key: 'meetings.tag.decision', icon: }, + { key: 'meetings.tag.todo', icon: }, + { key: 'meetings.tag.agenda', icon: }, + { key: 'meetings.tag.idea', icon: }, + { key: 'meetings.tag.issue', icon: }, ] +const PROCESSING_STEP_KEYS: Record = { + merging: 'meeting.processingStep.merging', + generating: 'meeting.processingStep.generating', + parsing: 'meeting.processingStep.parsing', + saving: 'meeting.processingStep.saving', + notifying: 'meeting.processingStep.notifying', +} + export function MeetingModePage(): React.ReactElement { const { t } = useI18n() - const [view, setView] = useState('list') - const [sessions, setSessions] = useState([]) - const [totalSessions, setTotalSessions] = useState(0) - const [loading, setLoading] = useState(true) - const [page] = useState(1) - const pageSize = 20 + const notify = useNotify() + const [view, setViewState] = useState('list') + // main 이벤트 핸들러가 렌더를 기다리지 않고 현재 뷰를 읽게 한다(완료 → 상세 전환 직후 도착한 idle 이 + // 옛 클로저로 목록을 덮지 않게). + const viewRef = useRef('list') + const setView = useCallback((next: MeetingView) => { + viewRef.current = next + setViewState(next) + }, []) + const [listQuery, setListQuery] = useState('') // 사전 점검 및 기동 상태 const [isStarting, setIsStarting] = useState(false) const [errorInfo, setErrorInfo] = useState(null) - const [sttProvider, setSttProvider] = useState('local') - const [, setSttEngineState] = useState('ready') - const [micDeviceName, setMicDeviceName] = useState('') + /** 조회한 값만 보인다 — 조회 전이면 null */ + const [sttProvider, setSttProvider] = useState(null) + const [micDeviceName, setMicDeviceName] = useState(null) // 녹음 뷰 상태 const [stateInfo, setStateInfo] = useState(null) @@ -121,30 +94,27 @@ export function MeetingModePage(): React.ReactElement { // 상세 뷰 상태 const [detail, setDetail] = useState(null) - const [snackbarMsg, setSnackbarMsg] = useState(null) - - // ── 사전 환경 점검 로드 ── - const checkPreflight = useCallback(async () => { - try { - const st = await window.electronAPI.stt.getStatus() - if (st.success && st.data) { - setSttEngineState(st.data.engineState) - } - const provRes = await window.electronAPI.config.get({ key: 'sttProvider' }) - if (provRes.success && provRes.data) { - setSttProvider(String(provRes.data)) - } - const devsRes = await window.electronAPI.audio.getDevices() - if (devsRes.success && devsRes.data && devsRes.data.length > 0) { - const def = devsRes.data.find((d) => d.isDefault) || devsRes.data[0] - setMicDeviceName(def.label || '기본 마이크') - } - } catch { /* ignore */ } - }, []) + // ── 사전 환경 점검 로드 ── (헤더 설명 한 줄: 마이크 · 음성 인식) useEffect(() => { - checkPreflight() - }, [checkPreflight]) + let cancelled = false + void (async () => { + try { + const provRes = await window.electronAPI.config.get({ key: 'sttProvider' }) + if (!cancelled && provRes.success && provRes.data) setSttProvider(String(provRes.data)) + const devsRes = await window.electronAPI.audio.getDevices() + if (!cancelled && devsRes.success && devsRes.data && devsRes.data.length > 0) { + const def = devsRes.data.find((d) => d.isDefault) || devsRes.data[0] + setMicDeviceName(def.label || '') + } + } catch { + /* 조회 실패 — 설명 줄에서 빼고 보인다 */ + } + })() + return () => { + cancelled = true + } + }, []) // ── 진행 중 세션 복원 ── // 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로 @@ -162,7 +132,8 @@ export function MeetingModePage(): React.ReactElement { setStateInfo(info) setLiveSessionId(info.sessionId) setElapsedMs(info.elapsedMs) - setView('recording') + // 다른 화면이 특정 회의 상세를 열어 달라고 했으면 그 요청이 이긴다 + if (viewRef.current !== 'detail') setView('recording') const live = await window.electronAPI.meetingMode.getLiveSession() if (cancelled || !live.success || !live.data || live.data.sessionId !== info.sessionId) return const restored = live.data @@ -173,95 +144,31 @@ export function MeetingModePage(): React.ReactElement { return () => { cancelled = true } - }, []) + }, [setView]) - // ── 에러 파서 ── - const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => { - const msg = (errMessage ?? '').toLowerCase() - const code = errCode - - if (code === 1400 || msg.includes('활성') || msg.includes('자막') || msg.includes('이미') || msg.includes('already') || msg.includes('collision')) { - return { - type: 'collision', - title: '이전 회의 또는 실시간 자막 충돌', - code: code ?? 'SESSION_COLLISION', - message: errMessage || '이전 회의 세션 또는 자막 모드가 백그라운드에 여전히 활성화되어 있습니다.', - reason: '백그라운드에서 동작 중이던 회의/자막 프로세스가 완전히 해제되지 않아 충돌했습니다.', - suggestion: '기존 프로세스를 강제 초기화한 후 즉시 새 회의를 시작할 수 있습니다.', - } - } - - if (code === 101 || (code !== undefined && Number(code) >= 100 && Number(code) <= 120) || msg.includes('stt') || msg.includes('whisper') || msg.includes('sidecar') || msg.includes('python') || msg.includes('model')) { - return { - type: 'stt', - title: '음성 인식(STT) 엔진 준비 실패', - code: code ?? 'STT_UNAVAILABLE', - message: errMessage || 'STT 음성 인식 엔진 또는 Whisper 모델 로드에 실패했습니다.', - reason: '로컬 Whisper 모델 파일이 준비되지 않았거나, 사이드카 프로세스 포트가 점유되었습니다.', - suggestion: 'STT 설정에서 모델 다운로드 상태를 확인하거나 강제 초기화 후 다시 시도하세요.', - } - } - - if ((code !== undefined && Number(code) >= 200 && Number(code) <= 220) || msg.includes('audio') || msg.includes('mic') || msg.includes('장치') || msg.includes('sox')) { - return { - type: 'audio', - title: '마이크 캡처 시작 실패', - code: code ?? 'MIC_CAPTURE_FAILED', - message: errMessage || '마이크 오디오 스트림을 시작할 수 없습니다.', - reason: '기본 마이크 입력 장치를 찾을 수 없거나 Windows 마이크 권한이 비활성화되어 있습니다.', - suggestion: '오디오 설정에서 마이크 장치를 선택하거나 기본 장치로 재시도하세요.', - } - } - - return { - type: 'general', - title: '회의를 시작할 수 없습니다', - code: code ?? 'MEETING_START_ERROR', - message: errMessage || '알 수 없는 오류로 회의 녹음을 시작하지 못했습니다.', - reason: '백그라운드 통신 또는 초기화 중 문제가 발생했습니다.', - suggestion: '프로세스를 강제 초기화한 후 다시 시작해 보세요.', - } - }, []) - - // ── 세션 목록 로드 ── - const loadSessions = useCallback(async () => { - setLoading(true) - const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize }) - if (resp.success) { - setSessions(resp.data.sessions) - setTotalSessions(resp.data.total) - } - setLoading(false) - }, [page]) - - useEffect(() => { - if (view === 'list') loadSessions() - }, [view, loadSessions]) - - // 다른 기기(모바일·웹)의 회의 변경이 반영되면: 목록은 다시 불러오고, - // 보고 있던 회의가 지워졌으면 목록으로 돌아간다(편집 중인 상세는 덮어쓰지 않는다). + // 다른 기기(모바일·웹)에서 보고 있던 회의가 지워졌으면 목록으로 돌아간다(편집 중인 상세는 덮어쓰지 않는다). + // 목록의 다시 불러오기는 MeetingListView 가 맡는다. useEffect( () => window.electronAPI.app.onDataChanged((data) => { - if (data.type !== 'cloud-sync') return + if (data.type !== 'cloud-sync' || viewRef.current !== 'detail' || !detail) return const entities = data.entities ?? [] if (!entities.some((e) => e === 'meetings' || e === 'meeting_memos' || e === 'meeting_documents')) return - if (view === 'list') { - loadSessions() - } else if (view === 'detail' && detail) { - window.electronAPI.meetingMode.getSession({ sessionId: detail.id }).then((resp) => { - if (!resp.success) setView('list') - }) - } + window.electronAPI.meetingMode.getSession({ sessionId: detail.id }).then((resp) => { + if (!resp.success) { + setDetail(null) + setView('list') + } + }) }), - [view, detail, loadSessions], + [detail, setView], ) // ── IPC 이벤트 구독 ── useEffect(() => { const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => { setStateInfo(info) - if (info.state === 'idle' && view === 'recording') { + if (info.state === 'idle' && viewRef.current === 'recording') { setProgress(null) setView('list') } @@ -280,16 +187,19 @@ export function MeetingModePage(): React.ReactElement { setProgress(p) }) - const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => { + // 녹음을 마치고 저장이 끝나면 그 회의의 상세(요약 먼저)로 간다 — 녹음 화면을 보고 있던 경우만. + const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted((completed) => { setProgress(null) - setView('list') + if (viewRef.current !== 'recording') return + setDetail(completed) + setView('detail') }) const unsubError = window.electronAPI.meetingMode.onError((e) => { setProgress(null) - setView('list') - const parsed = parseMeetingError(e.message, e.code) - setErrorInfo(parsed) + if (viewRef.current === 'recording') setView('list') + // 이 채널은 정지 후 후처리 실패에서만 온다 — '시작 실패'로 보이거나 새 녹음을 여는 버튼을 주면 안 된다. + setErrorInfo(buildMeetingErrorInfo('meetingProcessing', e.message, e.code, t)) }) const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => { @@ -304,7 +214,7 @@ export function MeetingModePage(): React.ReactElement { unsubError() unsubAudioLevel() } - }, [view, parseMeetingError]) + }, [setView, t]) // ── 경과 시간 타이머 ── useEffect(() => { @@ -336,19 +246,18 @@ export function MeetingModePage(): React.ReactElement { setMemos([]) setElapsedMs(0) setProgress(null) + setDetail(null) setView('recording') } else { - const parsed = parseMeetingError(resp.error?.message, resp.error?.code) - setErrorInfo(parsed) + setErrorInfo(buildMeetingErrorInfo('meetingStart', resp.error?.message, resp.error?.code, t)) } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) - const parsed = parseMeetingError(msg) - setErrorInfo(parsed) + setErrorInfo(buildMeetingErrorInfo('meetingStart', msg, undefined, t)) } finally { setIsStarting(false) } - }, [parseMeetingError]) + }, [setView, t]) // 정지는 마지막 문장 인식(flush)이 끝날 때까지 걸린다 — 그동안 다시 누를 수 없게 하고, // 실패하면 알린다(예전엔 응답을 버려 화면이 계속 '녹음 중'이었다). @@ -360,14 +269,17 @@ export function MeetingModePage(): React.ReactElement { setIsStopping(true) try { const resp = await window.electronAPI.meetingMode.stopRecording() - if (!resp.success) setSnackbarMsg(`${t('meeting.error')}: ${resp.error.message}`) + if (!resp.success) { + notify({ message: t('meetings.live.stopFailed', { message: resp.error.message }), severity: 'error' }) + } } catch (err: unknown) { - setSnackbarMsg(`${t('meeting.error')}: ${err instanceof Error ? err.message : String(err)}`) + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.live.stopFailed', { message }), severity: 'error' }) } finally { isStoppingRef.current = false setIsStopping(false) } - }, [t]) + }, [notify, t]) // ── 실시간 전사 세그먼트 즉시 수정 핸들러 ── // 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다. @@ -379,7 +291,7 @@ export function MeetingModePage(): React.ReactElement { setSegments((prev) => replaceSegmentText(prev, segmentId, newText)) const rollback = (message: string): void => { setSegments((prev) => rollbackSegmentText(prev, segmentId, newText, previousText)) - setSnackbarMsg(message) + notify({ message: t('meetings.live.editFailed', { message }), severity: 'error' }) } if (!liveSessionIdForEdit) { rollback(t('meeting.error')) @@ -396,28 +308,34 @@ export function MeetingModePage(): React.ReactElement { rollback(err instanceof Error ? err.message : String(err)) } }, - [liveSessionIdForEdit, t], + [liveSessionIdForEdit, notify, t], ) const isAddingMemoRef = useRef(false) const isViewingDetailRef = useRef(false) + // 메모 저장이 실패하면 적은 글을 입력창에 되돌리고 알린다(예전엔 입력만 비우고 조용히 버렸다). const handleAddMemo = useCallback(async () => { if (!memoInput.trim() || isAddingMemoRef.current) return isAddingMemoRef.current = true const text = memoInput.trim() setMemoInput('') + const restore = (message: string): void => { + setMemoInput((current) => (current ? `${text} ${current}` : text)) + notify({ message: t('meetings.live.memoFailed', { message }), severity: 'error' }) + } try { const resp = await window.electronAPI.meetingMode.addMemo({ content: text }) - if (resp.success) { - setMemos((prev) => [...prev, resp.data]) - } + if (resp.success) setMemos((prev) => [...prev, resp.data]) + else restore(resp.error.message) + } catch (err: unknown) { + restore(err instanceof Error ? err.message : String(err)) } finally { isAddingMemoRef.current = false } - }, [memoInput]) + }, [memoInput, notify, t]) - const handleTagClick = (tag: string) => { + const handleTagClick = (tag: string): void => { setMemoInput((prev) => (prev ? `${tag} ${prev}` : `${tag} `)) } @@ -426,14 +344,22 @@ export function MeetingModePage(): React.ReactElement { if (isImeComposingEvent(e)) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - handleAddMemo() + void handleAddMemo() } }, [handleAddMemo], ) + const liveActive = isActiveMeetingState(stateInfo?.state) + const liveId = stateInfo?.sessionId ?? liveSessionId + const handleViewDetail = useCallback( async (sessionId: string) => { + // 진행 중인 녹음의 줄을 누르면 녹음 화면으로 + if (liveActive && sessionId === liveId) { + setView('recording') + return + } if (isViewingDetailRef.current) return isViewingDetailRef.current = true try { @@ -442,518 +368,295 @@ export function MeetingModePage(): React.ReactElement { setDetail(resp.data) setView('detail') } else { - setSnackbarMsg(t('meeting.viewDetailError')) + notify({ message: t('meeting.viewDetailError'), severity: 'error' }) } + } catch { + notify({ message: t('meeting.viewDetailError'), severity: 'error' }) } finally { isViewingDetailRef.current = false } }, - [t], + [liveActive, liveId, notify, setView, t], ) - const formatTime = (ms: number): string => { - const totalSec = Math.floor(ms / 1000) - const min = Math.floor(totalSec / 60) - const sec = totalSec % 60 - return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}` - } + // 상세에서 나가면 — 녹음이 진행 중이면 녹음 화면, 아니면 목록 + const leaveDetail = useCallback(() => { + setDetail(null) + setView(liveActive ? 'recording' : 'list') + }, [liveActive, setView]) - const formatDate = (ts: number): string => { - const d = new Date(ts) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` - } - - const statusTone = (status: string): 'success' | 'warning' | 'error' | 'accent' => { - switch (status) { - case 'completed': - return 'success' - case 'recording': - return 'error' - case 'processing': - return 'warning' - case 'error': - return 'error' - default: - return 'accent' + // ── 다른 화면이 넘긴 요청 ── (홈의 "새 회의" · 최근 회의 줄) + useRouteRequest('meetings', (params) => { + if (params.meetingSessionId) { + void handleViewDetail(params.meetingSessionId) + return } - } - - const snackbar = ( - setSnackbarMsg(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - setSnackbarMsg(null)} sx={{ width: '100%' }}> - {snackbarMsg} - - - ) + if (params.startMeeting) { + // 이미 녹음 중이면 새로 시작하지 않는다(충돌 → 강제 초기화 버튼이 진행 중 회의를 끊지 않게) + void window.electronAPI.meetingMode + .getState() + .then((resp) => { + if (resp.success && isActiveMeetingState(resp.data.state)) return + void handleStartRecording(false) + }) + .catch(() => void handleStartRecording(false)) + } + }) const errorDialog = ( - setErrorInfo(null)} - maxWidth="sm" - fullWidth - PaperProps={{ - sx: { - bgcolor: d3roPalette.bg.card, - borderRadius: d3roRadius.doubleBezelOuter, - border: `1px solid ${d3roPalette.tag.red}`, - boxShadow: d3roShadow.dialog, - p: 1.5, - }, - }} - > - - - - {errorInfo?.title} - - - - - - - - {errorInfo?.code ? `ERR: ${errorInfo.code}` : 'ERROR'} - - - Meeting Intelligence Diagnostics - - - - - {errorInfo?.message} - - - - - - - - 발생 원인 - - - {errorInfo?.reason} - - - - - 해결 조치 - - - {errorInfo?.suggestion} - - - - - - - - - - {errorInfo?.type === 'stt' && ( - { - setErrorInfo(null) - openSettings('stt') - }} - leadingIcon={} - > - STT / 모델 설정 열기 - - )} - - {errorInfo?.type === 'audio' && ( - { - setErrorInfo(null) - openSettings('audio') - }} - leadingIcon={} - > - 마이크 / 오디오 설정 열기 - - )} - - { - setErrorInfo(null) - handleStartRecording(true) - }} - leadingIcon={} - > - {errorInfo?.type === 'collision' - ? '기존 프로세스 초기화 후 즉시 시작' - : '강제 초기화 후 다시 시도'} - - - + onForceRestart={() => void handleStartRecording(true)} + /> ) // ── 렌더: 목록 뷰 ── if (view === 'list') { return ( - - - {totalSessions > 0 && ( - - {t('meeting.sessions', { count: totalSessions })} - - )} - - - {micDeviceName || '기본 마이크'} - - - - {sttProvider === 'local' ? 'Local Whisper' : sttProvider.toUpperCase()} - - - } - action={ - handleStartRecording(false)} - disabled={isStarting} - trailingIcon={isStarting ? : } - > - {isStarting ? '회의 준비 중...' : t('meeting.newMeeting')} - - } - /> - - {loading ? ( - - {t('common.loading')} - - ) : sessions.length === 0 ? ( - } - action={ - handleStartRecording(false)} disabled={isStarting}> - {isStarting ? ( - - ) : ( - - )} - {isStarting ? '회의 준비 중...' : t('meeting.newMeeting')} - - } - /> - ) : ( - - {sessions.map((session) => ( - handleViewDetail(session.id)} - sx={{ - cursor: 'pointer', - transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)', - '&:hover': { transform: 'translateY(-2px)' }, - }} - > - - {/* Status Badge Row */} - - - {session.status === 'completed' - ? t('meeting.completed') - : session.status === 'error' - ? t('meeting.error') - : session.status === 'processing' - ? t('meeting.processing') - : t('meeting.recording')} - - - - {formatDate(session.startedAt)} - - - - {/* Title */} - - {session.title ?? t('meeting.untitled')} - - - {/* Metadata Footer */} - - - - {session.durationMs != null - ? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) }) - : '—'} - - - - {session.memoCount} Memos - - - - {/* Processing Progress Bar */} - {session.status === 'processing' && progress && progress.sessionId === session.id && ( - - - - )} - - - ))} - - )} - {snackbar} + void handleStartRecording(false)} + onOpen={(id) => void handleViewDetail(id)} + progress={progress} + micLabel={micDeviceName === null ? null : micDeviceName || t('meetings.defaultMic')} + engineLabel={ + sttProvider === null ? null : sttProvider === 'local' ? t('meetings.engine.local') : sttProvider.toUpperCase() + } + > {errorDialog} -
    + ) } - // ── 렌더: 실시간 녹음 & 온더플라이 수정 + 문맥 보강 메모 뷰 ── + // ── 렌더: 녹음 중 — 전사(주) + 회의 메모(보조) ── if (view === 'recording') { const isProcessing = stateInfo?.state === 'processing' return ( - - {/* Top Recording Cockpit */} - - - - - + + + + {isProcessing ? t('meetings.status.processing') : t('meetings.status.recording')} + + - {formatTime(elapsedMs)} - - - {isProcessing ? t('meeting.processing') : t('meeting.recording')} - + {formatElapsed(elapsedMs)} + - - {!isProcessing && ( - - - {t('meeting.stopRecording')} + } + action={ + !isProcessing ? ( + void handleStopRecording()} + disabled={isStopping} + startIcon={} + > + {isStopping ? t('meetings.live.stopping') : t('meeting.stopRecording')} - )} - - + ) : undefined + } + /> - {/* Processing Progress */} + {/* 저장 진행 */} {isProcessing && progress && ( - - - - {t(`meeting.processingStep.${progress.step}` as Parameters[0])} ({progress.percent}%) - - + + + + {t(PROCESSING_STEP_KEYS[progress.step])} ({progress.percent}%) + + )} - {/* Main Recording Workspace */} - - {/* Left: Real-time Interactive Transcript (클릭하여 지난 발화 즉시 수정 가능!) */} - - - - {t('meeting.transcript')} - - (클릭하여 지난 발화 즉시 수정 가능) - - - - {segments.length} Segments - + {/* 두 열 — 본문 폭 기준으로 접는다(MUI lg 브레이크포인트는 기본 창에서 오지 않는다). + 전사는 남는 폭을 모두 쓰고 메모는 약 300px. 둘이 한 줄에 안 들어가면 메모가 아래로 내려간다. */} + + {/* 전사 */} + + + + {t('meetings.live.transcript')} + + + {t('meetings.live.segmentCount', { count: segments.length })} + + + {t('meetings.live.editHint')} + - {segments.map((seg) => ( - - ))} + {segments.length === 0 ? ( + + {t('meetings.live.waiting')} + + ) : ( + segments.map((seg) => ( + + )) + )} - + - {/* Right: Live Context Enricher & Quick Tags Memos */} - - - - - CONTEXT ENRICHER - - - {memos.length} Memos - + {/* 회의 메모 */} + + + + {t('meetings.live.memoTitle')} + + {memos.length > 0 && ( + + {memos.length} + + )} - - - 실시간 작성된 메모는 AI 회의록 생성 시 최우선 문맥으로 반영됩니다. + + {t('meetings.live.memoHint')} - {/* Quick Context Category Chips */} + {/* 빠른 태그 */} - {CONTEXT_TAGS.map((ct) => ( - handleTagClick(ct.label)} - sx={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 0.5 }} - > - {ct.icon} - {ct.label} - - ))} + {CONTEXT_TAGS.map((ct) => { + const label = t(ct.key) + return ( + handleTagClick(label)} + sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }} + > + {ct.icon} + {label} + + ) + })} - {/* Memos List */} - + {/* 메모 목록 */} + {memos.map((memo) => ( - - {formatTime(memo.timestampMs)} - - - {memo.content} - + + {formatElapsed(memo.timestampMs)} + + {memo.content} ))} - {/* Memo Input */} {!isProcessing && ( setMemoInput(e.target.value)} onKeyDown={handleMemoKeyDown} + slotProps={{ htmlInput: { 'aria-label': t('meetings.live.memoTitle') } }} sx={{ '& .MuiInputBase-root': { fontFamily: d3roFontSans, - fontSize: '13px', - bgcolor: d3roPalette.bg.inset, + ...typoSx('compact'), + bgcolor: d3roPalette.bg.input, borderRadius: d3roRadius.inner, }, }} /> )} - +
    - {/* Live Audio Spectrum Meter */} + {/* 입력 레벨 */} {!isProcessing && ( - - + + )} - {snackbar} {errorDialog} ) @@ -963,7 +666,15 @@ export function MeetingModePage(): React.ReactElement { if (view === 'detail' && detail) { return ( <> - { setDetail(null); setView('list') }} /> + { + leaveDetail() + notify({ message: t('meetings.deleted'), severity: 'success' }) + }} + /> {errorDialog} ) diff --git a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx index 05d5791..1f45b57 100644 --- a/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx +++ b/apps/desktop/src/renderer/pages/VoiceConversationPage.tsx @@ -1,21 +1,22 @@ // src/renderer/pages/VoiceConversationPage.tsx -// Real-Time Duplex Conversational Voice Assistant Canvas +// AI 대화 — 말하거나 입력해 AI 와 주고받는 화면(2급 "도구"). +// +// 2026-09-28 개편: 글래스·글로우 말풍선 → 평면 + 1px 선, 비대칭 radius 는 토큰(d3roRadius)으로. +// 상태 표지는 세션이 켜져 있을 때만(꺼진 대화를 '대기'로 쓰지 않는다), 강조색은 듣는 중에만. +// 오류 알림은 공용 useNotify, 대화 기록 지우기는 되돌릴 수 없으므로 ConfirmDialog. import React, { useState, useEffect, useCallback, useRef } from 'react' -import { Alert, Box, IconButton, Snackbar, TextField, Tooltip, Typography } from '@mui/material' +import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material' import { Mic, Square, Send, Trash2, XCircle, Bot } from 'lucide-react' -import { - PhosphorText, - PhysicalButton, - DoubleBezelCard, - TactileBadge, -} from '@d3ro/ui/components/ds' -import { PageHeader } from '../components/shared' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { ConfirmDialog, PageHeader, useNotify } from '../components/shared' import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel' import { useRealtimeConversation } from '../hooks/useRealtimeConversation' import { isImeComposingEvent } from '../utils/keyboard' -import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import { TOOL_PAGE_SX } from './tools/ToolSection' +import { conversationBadge } from './tools/tools-model' import type { ConversationState, ConversationMessage, @@ -23,8 +24,41 @@ import type { ConversationError, } from '@d3ro/core/types' +/** 말풍선 모서리 — 말한 쪽 아래 모서리만 작게 */ +const BUBBLE_RADIUS = { + user: `${d3roRadius.card} ${d3roRadius.card} ${d3roRadius.xs} ${d3roRadius.card}`, + assistant: `${d3roRadius.card} ${d3roRadius.card} ${d3roRadius.card} ${d3roRadius.xs}`, +} as const + +const BUBBLE_TEXT_SX = { ...typoSx('body'), lineHeight: 1.65, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } as const + +/** 어시스턴트 쪽 표지 — 작은 원 안의 아이콘 */ +function AssistantMark(): React.ReactElement { + return ( + + + + ) +} + export function VoiceConversationPage(): React.ReactElement { const { t } = useI18n() + const notify = useNotify() + const [confirmClear, setConfirmClear] = useState(false) const [localState, setLocalState] = useState('idle') const [localMessages, setLocalMessages] = useState([]) const [localActive, setLocalActive] = useState(false) @@ -195,12 +229,14 @@ export function VoiceConversationPage(): React.ReactElement { realtime.clearMessages() return } - await window.electronAPI.voiceConversation.clearHistory() - setLocalMessages([]) + const res = await window.electronAPI.voiceConversation.clearHistory() + if (res.success) setLocalMessages([]) + else notify({ message: t('tools.conversation.clearFailed'), severity: 'error' }) } finally { isClearingRef.current = false + setConfirmClear(false) } - }, [isRealtime, realtime]) + }, [isRealtime, realtime, notify, t]) const handleCancelResponse = useCallback(async () => { if (isRealtime) { @@ -221,18 +257,6 @@ export function VoiceConversationPage(): React.ReactElement { [handleSendText], ) - const stateLabel = { - idle: t('conversation.idle'), - listening: t('conversation.listening'), - thinking: t('conversation.thinking'), - speaking: t('conversation.speaking'), - } - - const displayStateLabel = - isRealtime && realtime.connectionState === 'connecting' - ? t('conversation.realtime.connecting') - : stateLabel[state] - const formatErrorMessage = useCallback( (err: ConversationError): string => { if (err.phase === 'stt' && err.message.toLowerCase().startsWith('no speech')) { @@ -244,103 +268,68 @@ export function VoiceConversationPage(): React.ReactElement { [t], ) - const stateLedColor = { - idle: 'amber' as const, - listening: 'red' as const, - thinking: 'amber' as const, - speaking: 'green' as const, - } + // 오류는 공용 알림으로 한 번 띄우고 비운다 + useEffect(() => { + if (!errorBanner) return + notify({ message: formatErrorMessage(errorBanner), severity: 'warning' }) + setErrorBanner(null) + }, [errorBanner, formatErrorMessage, notify]) + + const badge = conversationBadge({ + active: isActive, + state, + connecting: isRealtime && realtime.connectionState === 'connecting', + }) + const busy = state === 'thinking' || state === 'speaking' return ( - + - {isRealtime && ( - - {t('conversation.realtime.badge')} + <> + {isRealtime && {t('tools.conversation.modeRealtime')}} + {badge && ( + + {t(badge.labelKey)} )} - - {displayStateLabel.toUpperCase()} - {messages.length > 0 && ( - + setConfirmClear(true)}> )} - + } /> - {/* Messages Canvas */} + {/* 대화 */} {!isRealtime && state === 'listening' ? ( ) : ( {messages.length === 0 && !streamingText && state !== 'thinking' && ( - - - + + + - - {t('conversation.empty')} - - + {t('conversation.empty')} + {t('conversation.emptyHint')} @@ -351,155 +340,78 @@ export function VoiceConversationPage(): React.ReactElement { return ( - {!isUser && ( - - - - )} - + {!isUser && } - - {msg.content} - + {msg.content} ) })} - {/* Thinking Typing Indicator */} + {/* 응답 준비 중 */} {state === 'thinking' && !streamingText && ( - + + span': { + width: 6, + height: 6, + borderRadius: d3roRadius.pill, + bgcolor: d3roPalette.text.tertiary, + animation: 'd3roTypingFade 1.2s infinite ease-in-out', + }, + '& > span:nth-of-type(2)': { animationDelay: '0.15s' }, + '& > span:nth-of-type(3)': { animationDelay: '0.3s' }, + '@keyframes d3roTypingFade': { + '0%, 80%, 100%': { opacity: 0.3 }, + '40%': { opacity: 1 }, + }, }} > - - - - span': { - width: 6, - height: 6, - borderRadius: '50%', - bgcolor: d3roPalette.accent.main, - animation: 'd3roTypingBounce 1.2s infinite ease-in-out', - }, - '& > span:nth-of-type(2)': { animationDelay: '0.15s' }, - '& > span:nth-of-type(3)': { animationDelay: '0.3s' }, - '@keyframes d3roTypingBounce': { - '0%, 80%, 100%': { opacity: 0.3, transform: 'translateY(0)' }, - '40%': { opacity: 1, transform: 'translateY(-3px)' }, - }, - }} - > - - - - + + + )} - {/* Streaming Text Display */} + {/* 스트리밍 중인 응답 */} {streamingText && streamingMsgId && ( - - - + - + {streamingText} - + {'▋'} @@ -511,98 +423,84 @@ export function VoiceConversationPage(): React.ReactElement { )} - {/* Bottom Interactive Control Cockpit */} - - - {/* Main Voice Button */} - {!isActive ? ( - - - - ) : state === 'listening' ? ( - - - - ) : state === 'thinking' || state === 'speaking' ? ( - - - - ) : ( - - - - )} - - {/* Text Input Bar */} - {(isRealtime || state !== 'listening') && ( - setTextInput(e.target.value)} - onKeyDown={handleTextKeyDown} - placeholder={ - state === 'thinking' || state === 'speaking' - ? t('conversation.thinking.placeholder') - : t('conversation.inputPlaceholder') - } - size="small" - fullWidth - disabled={state === 'thinking' || state === 'speaking'} - sx={{ - '& .MuiOutlinedInput-root': { - fontFamily: d3roFontSans, - fontSize: d3roTypo.compact.size, - bgcolor: d3roPalette.bg.inset, - borderRadius: d3roRadius.inner, - border: `1px solid ${d3roPalette.glass.hairline}`, - '& fieldset': { border: 'none' }, - '&:hover': { borderColor: d3roPalette.glass.hairlineStrong }, - '&.Mui-focused': { borderColor: d3roPalette.accent.main }, - }, - '& .MuiOutlinedInput-input': { - color: d3roPalette.text.primary, - py: 1.25, - }, - }} - /> - )} - - {/* Send Button */} - {(isRealtime || state !== 'listening') && ( - - - - )} - - {/* End Session Button */} - {isActive && ( - - {t('conversation.end')} - - )} - - - - {/* Error Notification */} - setErrorBanner(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + {/* 입력 줄 — 주 행동(말하기)만 채운 버튼 */} + - setErrorBanner(null)} sx={{ width: '100%' }}> - {errorBanner ? formatErrorMessage(errorBanner) : ''} - - + {!isActive ? ( + void handleStartSession()} sx={{ px: 1.5 }}> + + + ) : state === 'listening' ? ( + void (isRealtime ? handleStopSession() : handleFinishListening())} + sx={{ px: 1.5 }} + > + + + ) : busy ? ( + void handleCancelResponse()} sx={{ px: 1.5 }}> + + + ) : ( + // 세션은 켜져 있지만 듣고 있지 않다 — 여기서 다시 듣기를 시작하는 IPC 가 없어 누를 수 없게 둔다 + + + + )} + + {(isRealtime || state !== 'listening') && ( + setTextInput(e.target.value)} + onKeyDown={handleTextKeyDown} + placeholder={busy ? t('conversation.thinking.placeholder') : t('conversation.inputPlaceholder')} + slotProps={{ htmlInput: { 'aria-label': t('conversation.inputPlaceholder') } }} + size="small" + fullWidth + disabled={busy} + /> + )} + + {(isRealtime || state !== 'listening') && ( + + + void handleSendText()} + disabled={!textInput.trim() || busy} + > + + + + + )} + + {isActive && ( + void handleStopSession()} sx={{ flexShrink: 0 }}> + {t('conversation.end')} + + )} + + + void handleClearHistory()} + onClose={() => setConfirmClear(false)} + /> ) } diff --git a/apps/desktop/src/renderer/pages/history/ClearAllHistoryDialog.tsx b/apps/desktop/src/renderer/pages/history/ClearAllHistoryDialog.tsx index d04b2a0..fb7b603 100644 --- a/apps/desktop/src/renderer/pages/history/ClearAllHistoryDialog.tsx +++ b/apps/desktop/src/renderer/pages/history/ClearAllHistoryDialog.tsx @@ -1,17 +1,17 @@ // src/renderer/pages/history/ClearAllHistoryDialog.tsx -// 히스토리 전체 삭제 확인 다이얼로그 — 삭제 건수, 다른 기기 전파, 필터 무시 여부를 알린다. +// 기록 전체 삭제 확인 — 공용 ConfirmDialog 모양. 삭제 건수, 다른 기기 전파, 필터 무시 여부를 알린다. import React from 'react' -import { Dialog, DialogTitle, DialogContent, DialogActions, Box } from '@mui/material' -import { PhosphorText, PhysicalButton } from '@d3ro/ui/components/ds' -import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' +import { Box } from '@mui/material' +import { d3roPalette } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import { ConfirmDialog } from '../../components/shared' export interface ClearAllHistoryDialogProps { open: boolean /** 필터와 무관한 전체 삭제 대상 건수 */ count: number - /** 화면이 태그·검색으로 좁혀져 있으면 '보이는 것만 지워지지 않는다'를 알린다 */ + /** 화면이 검색·필터로 좁혀져 있으면 '보이는 것만 지워지지 않는다'를 알린다 */ filtered: boolean deleting: boolean onCancel: () => void @@ -29,66 +29,30 @@ export function ClearAllHistoryDialog({ const { t } = useI18n() return ( - - - {t('history.clearAll')} - - - - - - {t('history.clearAllConfirm', { count })} - + title={t('records.clearAllTitle')} + body={ + + + {t('records.clearAllBody', { count })} + {filtered && ( - - {t('history.clearAllIgnoresFilter')} - + {t('records.clearAllIgnoresFilter')} + )} - - - - - {t('common.cancel')} - - - {t('common.delete')} - - - + } + confirmLabel={t('records.clearAllConfirm')} + destructive + busy={deleting} + onConfirm={onConfirm} + onClose={onCancel} + /> ) } diff --git a/apps/desktop/src/renderer/pages/history/RecordsFilterBar.tsx b/apps/desktop/src/renderer/pages/history/RecordsFilterBar.tsx new file mode 100644 index 0000000..d3790a2 --- /dev/null +++ b/apps/desktop/src/renderer/pages/history/RecordsFilterBar.tsx @@ -0,0 +1,164 @@ +// src/renderer/pages/history/RecordsFilterBar.tsx +// 기록 필터 — 검색창 · 모드 칩 · 즐겨찾기 · 태그 칩. 검색과 태그를 함께 쓸 수 있다 +// (예전에는 태그를 고르면 검색창이 사라졌다). 걸린 조건이 있으면 "필터 지우기". + +import React, { useState } from 'react' +import { Box, ButtonBase, Typography } from '@mui/material' +import { Star } from 'lucide-react' +import { d3roPalette, d3roFocusRing, d3roRadius, d3roTransition, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { TagCount } from '@d3ro/core/types' +import { SearchInput } from '../../components/shared' +import { RECORD_MODE_FILTERS, isRecordsFilterActive, type RecordModeFilter, type RecordsFilter } from './records-view' + +/** 처음에 보일 태그 수 — 나머지는 "더 보기" */ +const VISIBLE_TAGS = 12 + +function FilterChip({ + selected, + onClick, + children, +}: { + selected: boolean + onClick: () => void + children: React.ReactNode +}): React.ReactElement { + return ( + + {children} + + ) +} + +export interface RecordsFilterBarProps { + /** 입력 중인 검색어 (조회는 잠시 뒤) */ + queryInput: string + onQueryChange: (value: string) => void + filter: RecordsFilter + tags: TagCount[] + onModeChange: (mode: RecordModeFilter) => void + onFavoritesToggle: () => void + onTagToggle: (tag: string) => void + onClear: () => void +} + +export function RecordsFilterBar({ + queryInput, + onQueryChange, + filter, + tags, + onModeChange, + onFavoritesToggle, + onTagToggle, + onClear, +}: RecordsFilterBarProps): React.ReactElement { + const { t } = useI18n() + const [showAllTags, setShowAllTags] = useState(false) + const active = isRecordsFilterActive({ ...filter, query: queryInput }) + + // 고른 태그는 접힌 목록 밖에 있어도 보이게 한다 + const visibleTags = showAllTags + ? tags + : tags.filter((tc, i) => i < VISIBLE_TAGS || tc.tag === filter.tag) + const hiddenCount = tags.length - visibleTags.length + + return ( + + + + + {RECORD_MODE_FILTERS.map((option) => ( + onModeChange(option.id)}> + {t(option.labelKey)} + + ))} + + + + {t('records.filter.favorites')} + + {active && ( + + {t('records.filter.clear')} + + )} + + + {tags.length > 0 && ( + + + {t('records.tags.label')} + + {visibleTags.map((tc) => ( + onTagToggle(tc.tag)}> + {`#${tc.tag}`} + + {tc.count} + + + ))} + {(hiddenCount > 0 || showAllTags) && tags.length > VISIBLE_TAGS && ( + setShowAllTags((v) => !v)} + sx={{ + ...typoSx('small'), + px: 0.75, + height: 28, + borderRadius: d3roRadius.xs, + color: d3roPalette.text.secondary, + '&:hover': { color: d3roPalette.text.primary }, + '&:focus-visible': d3roFocusRing, + }} + > + {showAllTags ? t('records.tags.showLess') : t('records.tags.showAll', { count: hiddenCount })} + + )} + + )} + + ) +} diff --git a/apps/desktop/src/renderer/pages/history/records-list-store.ts b/apps/desktop/src/renderer/pages/history/records-list-store.ts new file mode 100644 index 0000000..254d0e6 --- /dev/null +++ b/apps/desktop/src/renderer/pages/history/records-list-store.ts @@ -0,0 +1,180 @@ +// src/renderer/pages/history/records-list-store.ts +// 기록 목록의 불러오기 수명 — 첫 페이지, "더 불러오기", 새로 고침, 특정 기록 끼워 넣기. +// 조회 조건(source)이 바뀌면 세대 번호를 올려 늦게 도착한 이전 응답을 버린다. +// React·Electron 에 의존하지 않는다. IO 는 포트로 주입받는다(어댑터는 useRecordsList). + +import type { HistoryEntry, HistoryPage } from '@d3ro/core/types' +import { RECORDS_PAGE_SIZE, mergeRecords, sourceKey, type RecordsSource } from './records-view' + +export interface RecordsPort { + /** 실패하면 null */ + fetchPage(source: RecordsSource, page: number, pageSize: number): Promise + /** 없거나 실패하면 null */ + fetchById(id: string): Promise +} + +export interface RecordsListState { + entries: HistoryEntry[] + /** 이 조회 조건에서 서버가 가진 건수 */ + total: number + /** 불러온 페이지 수 (PAGE_SIZE 단위) */ + loadedPages: number + /** loading = 보여 줄 목록이 아직 없다 / error = 첫 페이지를 못 불러왔다 */ + status: 'loading' | 'ready' | 'error' + loadingMore: boolean + loadMoreFailed: boolean +} + +export const INITIAL_RECORDS_STATE: RecordsListState = { + entries: [], + total: 0, + loadedPages: 0, + status: 'loading', + loadingMore: false, + loadMoreFailed: false, +} + +export function hasMoreRecords(state: RecordsListState, pageSize = RECORDS_PAGE_SIZE): boolean { + return state.status === 'ready' && state.loadedPages * pageSize < state.total +} + +export class RecordsListStore { + private state: RecordsListState = INITIAL_RECORDS_STATE + private source: RecordsSource | null = null + private generation = 0 + /** ensureRecord 로 끼워 넣은 기록 — 같은 조건에서 새로 고쳐도 목록에 남긴다 */ + private pinned: HistoryEntry[] = [] + + constructor( + private readonly port: RecordsPort, + private readonly onChange: (state: RecordsListState) => void = () => undefined, + private readonly pageSize = RECORDS_PAGE_SIZE, + ) {} + + getState(): RecordsListState { + return this.state + } + + /** 조회 조건을 바꾸고 첫 페이지부터 다시 불러온다. 같은 조건이면 아무것도 하지 않는다 */ + async setSource(source: RecordsSource): Promise { + if (this.source && sourceKey(this.source) === sourceKey(source)) return + this.source = source + this.pinned = [] + const gen = ++this.generation + // 이전 결과는 새 결과가 올 때까지 둔다(검색어를 칠 때마다 목록이 비었다 차지 않도록). + this.set({ ...this.state, status: this.state.status === 'ready' ? 'ready' : 'loading', loadingMore: false, loadMoreFailed: false }) + const page = await this.safeFetch(source, 0, this.pageSize) + if (gen !== this.generation) return + if (!page) { + this.set({ ...INITIAL_RECORDS_STATE, status: 'error' }) + return + } + this.set({ + entries: mergeRecords(this.pinned, page.entries), + total: page.total, + loadedPages: 1, + status: 'ready', + loadingMore: false, + loadMoreFailed: false, + }) + } + + /** + * 지금까지 불러온 범위를 한 번에 다시 불러온다(데이터 변경·다시 시도). + * 목록이 이미 있으면 실패해도 그 목록을 둔다 — 틀린 목록이 아니라 조금 오래된 목록일 뿐이다. + */ + async reload(): Promise { + const source = this.source + if (!source) return + const gen = ++this.generation + const pages = Math.max(1, this.state.loadedPages) + if (this.state.status === 'error') this.set({ ...this.state, status: 'loading' }) + const page = await this.safeFetch(source, 0, pages * this.pageSize) + if (gen !== this.generation) return + if (!page) { + if (this.state.status !== 'ready') this.set({ ...INITIAL_RECORDS_STATE, status: 'error' }) + else this.set({ ...this.state, loadingMore: false }) + return + } + this.set({ + entries: mergeRecords(this.pinned, page.entries), + total: page.total, + loadedPages: pages, + status: 'ready', + loadingMore: false, + loadMoreFailed: false, + }) + } + + /** 다음 페이지를 이어 붙인다 */ + async loadMore(): Promise { + const source = this.source + if (!source || this.state.loadingMore || !hasMoreRecords(this.state, this.pageSize)) return + const gen = this.generation + const nextPage = this.state.loadedPages + this.set({ ...this.state, loadingMore: true, loadMoreFailed: false }) + const page = await this.safeFetch(source, nextPage, this.pageSize) + if (gen !== this.generation) return + if (!page) { + this.set({ ...this.state, loadingMore: false, loadMoreFailed: true }) + return + } + this.set({ + ...this.state, + entries: mergeRecords(this.state.entries, page.entries), + total: page.total, + loadedPages: nextPage + 1, + loadingMore: false, + loadMoreFailed: false, + }) + } + + /** 불러온 목록에 없는 기록을 한 건 가져와 끼워 넣는다. 목록에 있게 되면 true */ + async ensureRecord(id: string): Promise { + if (this.state.entries.some((e) => e.id === id)) return true + const gen = this.generation + let entry: HistoryEntry | null + try { + entry = await this.port.fetchById(id) + } catch { + entry = null + } + if (!entry || gen !== this.generation) return false + this.pinned = mergeRecords(this.pinned, [entry]) + this.set({ ...this.state, entries: mergeRecords(this.state.entries, [entry]) }) + return true + } + + /** 낙관적 갱신(즐겨찾기 등) */ + patch(id: string, update: Partial): void { + this.pinned = this.pinned.map((e) => (e.id === id ? { ...e, ...update } : e)) + this.set({ + ...this.state, + entries: this.state.entries.map((e) => (e.id === id ? { ...e, ...update } : e)), + }) + } + + /** 삭제가 확정된 기록을 목록에서 뺀다 */ + remove(id: string): void { + this.pinned = this.pinned.filter((e) => e.id !== id) + if (!this.state.entries.some((e) => e.id === id)) return + this.set({ + ...this.state, + entries: this.state.entries.filter((e) => e.id !== id), + total: Math.max(0, this.state.total - 1), + }) + } + + private async safeFetch(source: RecordsSource, page: number, pageSize: number): Promise { + try { + return await this.port.fetchPage(source, page, pageSize) + } catch { + return null + } + } + + private set(next: RecordsListState): void { + this.state = next + this.onChange(next) + } +} diff --git a/apps/desktop/src/renderer/pages/history/records-view.ts b/apps/desktop/src/renderer/pages/history/records-view.ts new file mode 100644 index 0000000..07ab8df --- /dev/null +++ b/apps/desktop/src/renderer/pages/history/records-view.ts @@ -0,0 +1,180 @@ +// src/renderer/pages/history/records-view.ts +// 기록 화면의 보기 규칙 — 필터 조합, 서버 조회 선택, 목록 병합, 날짜 묶음, 한 줄 미리보기, 복사 대상. +// React·Electron 에 의존하지 않는 순수 함수만 둔다(노드 테스트에서 그대로 검증). +// +// 서버(main)는 "전체 / 본문 검색 / 태그" 중 하나로만 조회할 수 있다(모드·즐겨찾기 조건 없음, 태그+검색 조합 없음). +// 그래서 서버로 가장 좁히는 조건 하나를 고르고, 나머지 조건은 불러온 페이지 위에서 거른다. + +import type { HistoryEntry } from '@d3ro/core/types' +import type { TranslationKey } from '@d3ro/i18n' +import { isHistoryViewFiltered } from './clear-all-history-flow' + +export const RECORDS_PAGE_SIZE = 50 + +export type RecordModeFilter = 'all' | 'dictation' | 'caption' | 'file' | 'commandTranslate' + +export const RECORD_MODE_FILTERS: ReadonlyArray<{ id: RecordModeFilter; labelKey: TranslationKey }> = [ + { id: 'all', labelKey: 'records.filter.all' }, + { id: 'dictation', labelKey: 'record.mode.dictation' }, + { id: 'caption', labelKey: 'record.mode.caption' }, + { id: 'file', labelKey: 'record.mode.file' }, + { id: 'commandTranslate', labelKey: 'records.filter.commandTranslate' }, +] + +const MODES_BY_FILTER: Record, ReadonlyArray> = { + dictation: ['dictation'], + caption: ['caption'], + file: ['file-transcription'], + commandTranslate: ['command', 'translate'], +} + +export interface RecordsFilter { + query: string + tag: string | null + mode: RecordModeFilter + favoritesOnly: boolean +} + +export const EMPTY_RECORDS_FILTER: RecordsFilter = { query: '', tag: null, mode: 'all', favoritesOnly: false } + +/** 검색·태그·모드·즐겨찾기 중 하나라도 걸려 있는지 */ +export function isRecordsFilterActive(filter: RecordsFilter): boolean { + return isHistoryViewFiltered(filter.tag, filter.query) || filter.mode !== 'all' || filter.favoritesOnly +} + +/** 서버에 맡길 조회 하나. 태그가 가장 좁으므로 태그 → 검색 → 전체 순 */ +export type RecordsSource = + | { kind: 'all' } + | { kind: 'search'; query: string } + | { kind: 'tag'; tag: string } + +export function selectRecordsSource(filter: RecordsFilter): RecordsSource { + if (filter.tag) return { kind: 'tag', tag: filter.tag } + const query = filter.query.trim() + if (query) return { kind: 'search', query } + return { kind: 'all' } +} + +export function sourceKey(source: RecordsSource): string { + switch (source.kind) { + case 'all': + return 'all' + case 'search': + return `search:${source.query}` + case 'tag': + return `tag:${source.tag}` + } +} + +export function matchesRecordMode(entry: Pick, mode: RecordModeFilter): boolean { + if (mode === 'all') return true + return MODES_BY_FILTER[mode].includes(entry.mode) +} + +/** 서버 검색(HistoryService.search)과 같은 필드를 대소문자 없이 본다 */ +export function matchesRecordText( + entry: Pick, + query: string, +): boolean { + const needle = query.trim().toLocaleLowerCase() + if (!needle) return true + return [entry.originalText, entry.polishedText, entry.title, entry.summaryText].some( + (field) => typeof field === 'string' && field.toLocaleLowerCase().includes(needle), + ) +} + +/** 서버가 거르지 못한 조건을 불러온 목록 위에서 거른다. hiddenIds 는 되돌리기를 기다리는 삭제 */ +export function applyRecordsFilter( + entries: readonly HistoryEntry[], + filter: RecordsFilter, + hiddenIds: ReadonlySet = new Set(), +): HistoryEntry[] { + const source = selectRecordsSource(filter) + const needsText = source.kind === 'tag' && filter.query.trim().length > 0 + return entries.filter( + (entry) => + !hiddenIds.has(entry.id) && + matchesRecordMode(entry, filter.mode) && + (!filter.favoritesOnly || entry.isFavorite) && + (!needsText || matchesRecordText(entry, filter.query)), + ) +} + +/** 페이지를 이어 붙이거나 한 건을 끼워 넣는다. 같은 id 는 새 값이 이기고, 최신순을 유지한다 */ +export function mergeRecords(existing: readonly HistoryEntry[], incoming: readonly HistoryEntry[]): HistoryEntry[] { + const byId = new Map() + for (const entry of existing) byId.set(entry.id, entry) + for (const entry of incoming) byId.set(entry.id, entry) + return [...byId.values()].sort((a, b) => b.createdAt - a.createdAt) +} + +export interface RecordDayGroup { + key: string + /** 그 날의 아무 타임스탬프 — 날짜 라벨용 */ + ts: number + entries: HistoryEntry[] +} + +/** 최신순 목록을 날짜별로 묶는다. dayKey 는 로컬 날짜 키(getDateKey) */ +export function groupRecordsByDay( + entries: readonly HistoryEntry[], + dayKey: (ts: number) => string, +): RecordDayGroup[] { + const groups: RecordDayGroup[] = [] + let current: RecordDayGroup | null = null + for (const entry of entries) { + const key = dayKey(entry.createdAt) + if (!current || current.key !== key) { + current = { key, ts: entry.createdAt, entries: [] } + groups.push(current) + } + current.entries.push(entry) + } + return groups +} + +export type RecordDayKind = 'today' | 'yesterday' | 'thisYear' | 'older' + +/** 날짜 묶음 라벨의 종류. now 기준 로컬 날짜로 판단한다 */ +export function recordDayKind(ts: number, now: number): RecordDayKind { + const day = new Date(ts) + day.setHours(0, 0, 0, 0) + const today = new Date(now) + today.setHours(0, 0, 0, 0) + const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000) + if (diffDays === 0) return 'today' + if (diffDays === 1) return 'yesterday' + return day.getFullYear() === today.getFullYear() ? 'thisYear' : 'older' +} + +/** 다듬은 글이 있으면 그것, 없으면 원문 — 화면 본문과 "복사"의 대상 */ +export function recordPrimaryText(entry: Pick): string { + const polished = entry.polishedText?.trim() + return polished ? (entry.polishedText as string) : entry.originalText +} + +/** 다듬은 글이 따로 있을 때만 원문을 돌려준다("원문 복사"는 이때만 보인다) */ +export function recordOriginalText(entry: Pick): string | null { + const polished = entry.polishedText?.trim() + if (!polished || entry.polishedText === entry.originalText) return null + return entry.originalText +} + +function firstLine(text: string): string { + const line = text + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) + return line ?? '' +} + +/** 한 줄 미리보기 — 제목이 있으면 제목 + 본문 첫 줄, 없으면 본문 첫 줄만 */ +export function recordPreview(entry: Pick): { + title: string + snippet: string | null +} { + const body = firstLine(recordPrimaryText(entry)) + const title = entry.title?.trim() + if (title) return { title, snippet: body && body !== title ? body : null } + return { title: body, snippet: null } +} diff --git a/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts b/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts index 7d18d96..5721814 100644 --- a/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts +++ b/apps/desktop/src/renderer/pages/history/useClearAllHistory.ts @@ -31,11 +31,11 @@ export interface ClearAllHistoryControls { } /** - * @param onSettled 삭제가 실행된 뒤(성공·실패 무관) 목록을 다시 불러오기 위한 콜백 + * @param onSettled 삭제가 실행된 뒤(성공·실패 무관) 불린다. deleted=false 면 삭제가 실패한 것 — 호출자가 알린다 * @param port 테스트·다른 호스트용 포트 주입 지점. 기본값은 Electron IPC */ export function useClearAllHistory( - onSettled: () => void, + onSettled: (deleted: boolean) => void, port?: ClearAllHistoryPort, ): ClearAllHistoryControls { const [state, setState] = useState(CLEAR_ALL_IDLE) @@ -54,7 +54,7 @@ export function useClearAllHistory( const confirm = useCallback(() => { if (flow.getState().phase !== 'confirming') return - void flow.confirm().finally(() => onSettledRef.current()) + void flow.confirm().then((deleted) => onSettledRef.current(deleted)) }, [flow]) const cancel = useCallback(() => { diff --git a/apps/desktop/src/renderer/pages/history/useRecordsList.ts b/apps/desktop/src/renderer/pages/history/useRecordsList.ts new file mode 100644 index 0000000..76d9578 --- /dev/null +++ b/apps/desktop/src/renderer/pages/history/useRecordsList.ts @@ -0,0 +1,66 @@ +// src/renderer/pages/history/useRecordsList.ts +// RecordsListStore 를 React 상태에 잇고, preload history/memo API 를 포트로 어댑팅한다. + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { HistoryEntry } from '@d3ro/core/types' +import { + INITIAL_RECORDS_STATE, + RecordsListStore, + type RecordsListState, + type RecordsPort, +} from './records-list-store' +import type { RecordsSource } from './records-view' + +/** preload API → RecordsPort */ +export function createElectronRecordsPort(): RecordsPort { + return { + async fetchPage(source, page, pageSize) { + const api = window.electronAPI + const result = + source.kind === 'tag' + ? await api.memo.searchByTag({ tag: source.tag, page, pageSize }) + : source.kind === 'search' + ? await api.history.search({ query: source.query, page, pageSize }) + : await api.history.getAll({ page, pageSize, sortOrder: 'desc' }) + return result.success ? result.data : null + }, + async fetchById(id) { + const result = await window.electronAPI.history.getById({ id }) + return result.success ? result.data : null + }, + } +} + +export interface RecordsListControls { + state: RecordsListState + reload: () => void + loadMore: () => void + ensureRecord: (id: string) => Promise + patch: (id: string, update: Partial) => void + remove: (id: string) => void +} + +export function useRecordsList(source: RecordsSource, port?: RecordsPort): RecordsListControls { + const [state, setState] = useState(INITIAL_RECORDS_STATE) + const storeRef = useRef(null) + if (storeRef.current === null) { + storeRef.current = new RecordsListStore(port ?? createElectronRecordsPort(), setState) + } + const store = storeRef.current + + const kind = source.kind + const value = source.kind === 'tag' ? source.tag : source.kind === 'search' ? source.query : '' + useEffect(() => { + const next: RecordsSource = + kind === 'tag' ? { kind, tag: value } : kind === 'search' ? { kind, query: value } : { kind: 'all' } + void store.setSource(next) + }, [store, kind, value]) + + const reload = useCallback(() => void store.reload(), [store]) + const loadMore = useCallback(() => void store.loadMore(), [store]) + const ensureRecord = useCallback((id: string) => store.ensureRecord(id), [store]) + const patch = useCallback((id: string, update: Partial) => store.patch(id, update), [store]) + const remove = useCallback((id: string) => store.remove(id), [store]) + + return { state, reload, loadMore, ensureRecord, patch, remove } +} diff --git a/apps/desktop/src/renderer/pages/meeting/MeetingErrorDialog.tsx b/apps/desktop/src/renderer/pages/meeting/MeetingErrorDialog.tsx new file mode 100644 index 0000000..7043f99 --- /dev/null +++ b/apps/desktop/src/renderer/pages/meeting/MeetingErrorDialog.tsx @@ -0,0 +1,110 @@ +// src/renderer/pages/meeting/MeetingErrorDialog.tsx +// 회의 시작·저장 실패 다이얼로그 — 무엇이(제목·메시지) · 왜(원인) · 어떻게(해결 방법 + 복구 버튼). +// 복구 버튼은 error-recovery 정책이 고른 것만 그린다 — 후처리 실패엔 새 녹음을 여는 버튼이 없다. + +import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' +import { AlertCircle, Mic, RefreshCw, Settings } from 'lucide-react' +import { PhysicalButton } from '@d3ro/ui/components/ds' +import { d3roFontMono, d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import { openSettings } from '../../navigation/settingsTabs' +import { forceRestartLabel, type MeetingErrorInfo } from './meeting-error' + +interface MeetingErrorDialogProps { + errorInfo: MeetingErrorInfo | null + onClose: () => void + /** 강제 초기화 후 다시 시작 */ + onForceRestart: () => void +} + +export function MeetingErrorDialog({ errorInfo, onClose, onForceRestart }: MeetingErrorDialogProps): React.ReactElement { + const { t } = useI18n() + + return ( + + + + {errorInfo?.title} + + + + + + {errorInfo?.message} + + {errorInfo?.code !== undefined && ( + + {String(errorInfo.code)} + + )} + + + + + {t('meetings.error.reason')} + + + {errorInfo?.reason} + + + {t('meetings.error.fix')} + + + {errorInfo?.suggestion} + + + + + + + + {errorInfo !== null && + errorInfo.recoveries.map((recovery) => { + if (recovery.kind === 'openSettings') { + return ( + { + onClose() + openSettings(recovery.tab) + }} + startIcon={recovery.tab === 'audio' ? : } + > + {recovery.tab === 'audio' ? t('meetings.error.openAudioSettings') : t('meetings.error.openSttSettings')} + + ) + } + return ( + { + onClose() + onForceRestart() + }} + startIcon={} + > + {forceRestartLabel(errorInfo.category, t)} + + ) + })} + + + ) +} diff --git a/apps/desktop/src/renderer/pages/meeting/MeetingListView.tsx b/apps/desktop/src/renderer/pages/meeting/MeetingListView.tsx new file mode 100644 index 0000000..061ad73 --- /dev/null +++ b/apps/desktop/src/renderer/pages/meeting/MeetingListView.tsx @@ -0,0 +1,452 @@ +// src/renderer/pages/meeting/MeetingListView.tsx +// 회의 목록 — 제목 "회의" + 주 행동 "새 회의" 하나. 한 열 목록(제목 · 날짜 · 길이 · 메모 수 · 상태), +// 제목 검색(불러온 목록 안), 줄마다 더 보기(제목 변경·삭제), 페이지가 나뉘면 "더 불러오기". +// 비어 있으면 주 행동은 빈 상태 안에 하나만 둔다(머리에 같은 버튼을 두 번 두지 않는다). + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Box, + Button, + ButtonBase, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + LinearProgress, + Menu, + MenuItem, + TextField, + Typography, +} from '@mui/material' +import { MoreHorizontal, Pencil, Plus, Trash2, Users } from 'lucide-react' +import { PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' +import { d3roFocusRing, d3roPalette, d3roRadius, d3roShadow, d3roTransition, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { MeetingProcessingProgress, MeetingSessionSummary } from '@d3ro/core/types' +import { ConfirmDialog, EmptyStateCard, PageHeader, SearchInput, useNotify } from '../../components/shared' +import { clearNotepad, defaultNotepadStorage } from '../../components/meeting/meeting-notepad-storage' +import { meetingStatusLabelKey, meetingStatusTone, shouldShowStatusBadge } from '../../components/meeting/meeting-format' +import { isImeComposingEvent } from '../../utils/keyboard' +import { formatClockTime, formatDayLabel, formatDurationLong } from '../../utils/formatters' +import { appendMeetingPage, filterMeetingsByTitle, hasMoreMeetings } from './meeting-list' + +const PAGE_SIZE = 20 + +interface MeetingListViewProps { + query: string + onQueryChange: (query: string) => void + isStarting: boolean + onStartMeeting: () => void + onOpen: (sessionId: string) => void + /** 후처리 진행률(저장 중인 회의 줄에 막대로) */ + progress: MeetingProcessingProgress | null + /** 헤더 설명 한 줄 — 조회한 값만(조회 전이면 null) */ + micLabel: string | null + engineLabel: string | null + /** 오류 다이얼로그 등 목록 뷰에 함께 그릴 것 */ + children?: React.ReactNode +} + +type LoadState = 'loading' | 'ready' | 'error' + +export function MeetingListView({ + query, + onQueryChange, + isStarting, + onStartMeeting, + onOpen, + progress, + micLabel, + engineLabel, + children, +}: MeetingListViewProps): React.ReactElement { + const { t, locale } = useI18n() + const notify = useNotify() + + const [sessions, setSessions] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loadState, setLoadState] = useState('loading') + const [loadingMore, setLoadingMore] = useState(false) + const requestRef = useRef(0) + + // ── 불러오기 ── (늦게 온 이전 응답이 새 목록을 덮지 않게 요청 번호로 거른다) + const loadFirst = useCallback(async () => { + const req = ++requestRef.current + setLoadState((prev) => (prev === 'ready' ? prev : 'loading')) + try { + const resp = await window.electronAPI.meetingMode.getSessions({ page: 1, pageSize: PAGE_SIZE }) + if (req !== requestRef.current) return + if (!resp.success) { + setLoadState('error') + return + } + setSessions(resp.data.sessions) + setTotal(resp.data.total) + setPage(1) + setLoadState('ready') + } catch { + if (req === requestRef.current) setLoadState('error') + } + }, []) + + const loadMore = useCallback(async () => { + if (loadingMore) return + const req = requestRef.current + setLoadingMore(true) + try { + const next = page + 1 + const resp = await window.electronAPI.meetingMode.getSessions({ page: next, pageSize: PAGE_SIZE }) + if (req !== requestRef.current) return + if (!resp.success) { + notify({ message: t('meetings.loadMoreError'), severity: 'error' }) + return + } + setSessions((prev) => appendMeetingPage(prev, resp.data.sessions)) + setTotal(resp.data.total) + setPage(next) + } catch { + notify({ message: t('meetings.loadMoreError'), severity: 'error' }) + } finally { + setLoadingMore(false) + } + }, [loadingMore, page, notify, t]) + + useEffect(() => { + void loadFirst() + }, [loadFirst]) + + // 다른 기기(모바일·웹)의 회의 변경이 반영되면 다시 불러온다 + useEffect( + () => + window.electronAPI.app.onDataChanged((data) => { + if (data.type !== 'cloud-sync') return + const entities = data.entities ?? [] + if (entities.some((e) => e === 'meetings' || e === 'meeting_memos' || e === 'meeting_documents')) { + void loadFirst() + } + }), + [loadFirst], + ) + + // 후처리가 끝나면(진행률이 사라지면) 상태 배지를 새로 고친다 + const hadProgressRef = useRef(false) + useEffect(() => { + if (progress) hadProgressRef.current = true + else if (hadProgressRef.current) { + hadProgressRef.current = false + void loadFirst() + } + }, [progress, loadFirst]) + + const untitled = t('meeting.untitled') + const visible = useMemo(() => filterMeetingsByTitle(sessions, query, untitled), [sessions, query, untitled]) + const moreAvailable = hasMoreMeetings(sessions.length, total) + + // ── 줄 메뉴 · 제목 변경 · 삭제 ── + const [menu, setMenu] = useState<{ anchor: HTMLElement; session: MeetingSessionSummary } | null>(null) + const [renaming, setRenaming] = useState(null) + const [renameDraft, setRenameDraft] = useState('') + const [renameBusy, setRenameBusy] = useState(false) + const [deleting, setDeleting] = useState(null) + const [deleteBusy, setDeleteBusy] = useState(false) + + const submitRename = async (): Promise => { + if (!renaming) return + const title = renameDraft.trim() + if (!title) return + setRenameBusy(true) + try { + const resp = await window.electronAPI.meetingMode.updateTitle({ sessionId: renaming.id, title }) + if (!resp.success) { + notify({ message: t('meetings.renameFailed', { message: resp.error.message }), severity: 'error' }) + return + } + const id = renaming.id + setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s))) + setRenaming(null) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.renameFailed', { message }), severity: 'error' }) + } finally { + setRenameBusy(false) + } + } + + const confirmDelete = async (): Promise => { + if (!deleting) return + const id = deleting.id + setDeleteBusy(true) + try { + const resp = await window.electronAPI.meetingMode.deleteSession({ sessionId: id }) + if (!resp.success) { + notify({ message: t('meetings.deleteFailed', { message: resp.error.message }), severity: 'error' }) + return + } + clearNotepad(defaultNotepadStorage(), id) + setSessions((prev) => prev.filter((s) => s.id !== id)) + setTotal((prev) => Math.max(0, prev - 1)) + setDeleting(null) + notify({ message: t('meetings.deleted'), severity: 'success' }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + notify({ message: t('meetings.deleteFailed', { message }), severity: 'error' }) + } finally { + setDeleteBusy(false) + } + } + + // ── 렌더 ── + const startButton = ( + : } + > + {isStarting ? t('meetings.starting') : t('meeting.newMeeting')} + + ) + + const isEmpty = loadState === 'ready' && total === 0 && sessions.length === 0 + const description = [ + micLabel !== null ? t('meetings.header.mic', { name: micLabel }) : null, + engineLabel !== null ? t('meetings.header.engine', { name: engineLabel }) : null, + ] + .filter((v): v is string => v !== null) + .join(' · ') + + return ( + + 0 ? t('meeting.sessions', { count: total }) : undefined} + description={description || undefined} + action={loadState === 'ready' && !isEmpty ? startButton : undefined} + /> + + {loadState === 'loading' ? ( + + + + ) : loadState === 'error' ? ( + void loadFirst()}> + {t('common.retry')} + + } + /> + ) : isEmpty ? ( + } + action={startButton} + /> + ) : ( + <> + + + {visible.length === 0 ? ( + + ) : ( + + {visible.map((session) => { + const title = session.title ?? untitled + const meta = [ + `${formatDayLabel(session.startedAt, locale)} ${formatClockTime(session.startedAt, locale)}`, + session.durationMs != null ? formatDurationLong(session.durationMs, locale) : null, + session.memoCount > 0 ? t('meetings.memoCount', { count: session.memoCount }) : null, + ] + .filter((v): v is string => v !== null) + .join(' · ') + const showProgress = session.status === 'processing' && progress?.sessionId === session.id + return ( + + onOpen(session.id)} + sx={{ + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 2, + px: 1, + py: 1.5, + textAlign: 'left', + borderRadius: d3roRadius.small, + transition: d3roTransition('background-color'), + '&:hover': { bgcolor: d3roPalette.surface.overlaySoft }, + '&:focus-visible': d3roFocusRing, + }} + > + + + {title} + + + {meta} + + {showProgress && progress && ( + + )} + + {shouldShowStatusBadge(session.status) && ( + + {t(meetingStatusLabelKey(session.status))} + + )} + + setMenu({ anchor: e.currentTarget, session })} + sx={{ color: d3roPalette.text.tertiary }} + > + + + + ) + })} + + )} + + {moreAvailable && ( + + + {t('meetings.shown', { shown: sessions.length, total })} + + void loadMore()} disabled={loadingMore}> + {loadingMore ? t('meetings.loadingMore') : t('meetings.loadMore')} + + + )} + + )} + + setMenu(null)} + slotProps={{ + paper: { + sx: { + bgcolor: d3roPalette.bg.elevated, + border: `1px solid ${d3roPalette.border.default}`, + borderRadius: d3roRadius.inner, + boxShadow: d3roShadow.overlay, + minWidth: 160, + }, + }, + }} + > + { + if (!menu) return + setRenameDraft(menu.session.title ?? '') + setRenaming(menu.session) + setMenu(null) + }} + sx={{ ...typoSx('compact'), gap: 1.25 }} + > + + {t('meetings.rename')} + + { + if (!menu) return + setDeleting(menu.session) + setMenu(null) + }} + sx={{ ...typoSx('compact'), gap: 1.25, color: d3roPalette.status.danger }} + > + + {t('common.delete')} + + + + setRenaming(null)} + maxWidth="xs" + fullWidth + aria-labelledby="meeting-rename-title" + > + + {t('meetings.renameDialog.title')} + + + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (isImeComposingEvent(e)) return + if (e.key === 'Enter') void submitRename() + }} + sx={{ mt: 1 }} + /> + + + + + + + + void confirmDelete()} + onClose={() => setDeleting(null)} + /> + + {children} + + ) +} diff --git a/apps/desktop/src/renderer/pages/meeting/meeting-error.ts b/apps/desktop/src/renderer/pages/meeting/meeting-error.ts new file mode 100644 index 0000000..99b38d5 --- /dev/null +++ b/apps/desktop/src/renderer/pages/meeting/meeting-error.ts @@ -0,0 +1,114 @@ +// src/renderer/pages/meeting/meeting-error.ts +// 회의 오류 다이얼로그 모델 — 분류·복구는 navigation/error-recovery 의 결정을 그대로 따르고, +// 여기서는 카테고리별 문구 키만 붙인다(순수 함수 — MeetingModePage 는 결과를 그리기만 한다). +// 문구는 meetings.error.* i18n 키다. translate 를 넘기지 않으면 키를 그대로 돌려준다(테스트·로그용). + +import type { TranslationKey } from '@d3ro/i18n' +import { presentError } from '../../navigation/error-recovery' +import type { ErrorCategory, ErrorContext, RecoveryAction } from '../../navigation/error-recovery' + +export type MeetingErrorContext = Extract + +export type MeetingErrorTranslate = (key: TranslationKey) => string + +const identity: MeetingErrorTranslate = (key) => key + +export interface MeetingErrorInfo { + category: ErrorCategory + title: string + code: number | string + message: string + reason: string + suggestion: string + recoveries: readonly RecoveryAction[] +} + +interface MeetingErrorCopy { + title: TranslationKey + fallbackCode: string + fallbackMessage: TranslationKey + reason: TranslationKey + suggestion: TranslationKey +} + +const COLLISION_COPY: MeetingErrorCopy = { + title: 'meetings.error.collision.title', + fallbackCode: 'SESSION_COLLISION', + fallbackMessage: 'meetings.error.collision.message', + reason: 'meetings.error.collision.reason', + suggestion: 'meetings.error.collision.suggestion', +} + +const STT_COPY: MeetingErrorCopy = { + title: 'meetings.error.stt.title', + fallbackCode: 'STT_UNAVAILABLE', + fallbackMessage: 'meetings.error.stt.message', + reason: 'meetings.error.stt.reason', + suggestion: 'meetings.error.stt.suggestion', +} + +const AUDIO_COPY: MeetingErrorCopy = { + title: 'meetings.error.audio.title', + fallbackCode: 'MIC_CAPTURE_FAILED', + fallbackMessage: 'meetings.error.audio.message', + reason: 'meetings.error.audio.reason', + suggestion: 'meetings.error.audio.suggestion', +} + +const PROCESSING_COPY: MeetingErrorCopy = { + title: 'meetings.error.processing.title', + fallbackCode: 'MEETING_PROCESSING_FAILED', + fallbackMessage: 'meetings.error.processing.message', + reason: 'meetings.error.processing.reason', + suggestion: 'meetings.error.processing.suggestion', +} + +const GENERAL_COPY: MeetingErrorCopy = { + title: 'meetings.error.general.title', + fallbackCode: 'MEETING_START_ERROR', + fallbackMessage: 'meetings.error.general.message', + reason: 'meetings.error.general.reason', + suggestion: 'meetings.error.general.suggestion', +} + +function copyFor(category: ErrorCategory): MeetingErrorCopy { + switch (category) { + case 'collision': + return COLLISION_COPY + case 'stt': + case 'sttModelMissing': + case 'sttEngine': + return STT_COPY + case 'audio': + return AUDIO_COPY + case 'processing': + return PROCESSING_COPY + default: + return GENERAL_COPY + } +} + +/** 회의 오류를 다이얼로그 모델로 바꾼다. */ +export function buildMeetingErrorInfo( + context: MeetingErrorContext, + message?: string, + code?: number | string, + translate: MeetingErrorTranslate = identity, +): MeetingErrorInfo { + const presentation = presentError({ context, code, message }) + const copy = copyFor(presentation.category) + return { + category: presentation.category, + title: translate(copy.title), + code: code ?? copy.fallbackCode, + message: message || translate(copy.fallbackMessage), + reason: translate(copy.reason), + suggestion: translate(copy.suggestion), + recoveries: presentation.recoveries, + } +} + +/** 강제 재시작 버튼 문구 — 충돌이면 '정리하고 시작', 그 밖은 '다시 시도'. */ +export function forceRestartLabel(category: ErrorCategory, translate: MeetingErrorTranslate = identity): string { + return translate(category === 'collision' ? 'meetings.error.forceStart' : 'meetings.error.forceRetry') +} diff --git a/apps/desktop/src/renderer/pages/meeting/meeting-list.ts b/apps/desktop/src/renderer/pages/meeting/meeting-list.ts new file mode 100644 index 0000000..c86be6e --- /dev/null +++ b/apps/desktop/src/renderer/pages/meeting/meeting-list.ts @@ -0,0 +1,37 @@ +// src/renderer/pages/meeting/meeting-list.ts +// 회의 목록 규칙(React 없음) — 페이지 이어 붙이기·제목 검색. 상태 라벨은 components/meeting/meeting-format. +// 검색은 불러온 목록의 제목만 거른다. 전사 본문 검색은 통합 검색(물결 ⑤)의 몫이다. + +import type { MeetingSessionSummary } from '@d3ro/core/types' + +/** 다음 페이지를 이어 붙인다. 그 사이 새 회의가 생겨 밀려 온 중복은 버린다(앞 페이지가 이긴다). */ +export function appendMeetingPage( + current: readonly MeetingSessionSummary[], + incoming: readonly MeetingSessionSummary[], +): MeetingSessionSummary[] { + const seen = new Set(current.map((s) => s.id)) + return [...current, ...incoming.filter((s) => !seen.has(s.id))] +} + +function normalize(text: string): string { + return text.normalize('NFC').toLocaleLowerCase().replace(/\s+/g, ' ').trim() +} + +/** + * 제목으로 거른다(대소문자·공백·유니코드 정규화 무시). 제목이 없는 회의는 화면에 보이는 대체 제목으로 찾는다. + * 검색어가 비면 그대로 돌려준다. + */ +export function filterMeetingsByTitle( + sessions: readonly MeetingSessionSummary[], + query: string, + untitledLabel: string, +): readonly MeetingSessionSummary[] { + const q = normalize(query) + if (!q) return sessions + return sessions.filter((s) => normalize(s.title ?? untitledLabel).includes(q)) +} + +/** 더 불러올 회의가 남았는가. */ +export function hasMoreMeetings(loaded: number, total: number): boolean { + return loaded < total +} diff --git a/apps/desktop/src/renderer/pages/tools/ToolSection.tsx b/apps/desktop/src/renderer/pages/tools/ToolSection.tsx new file mode 100644 index 0000000..56fe41c --- /dev/null +++ b/apps/desktop/src/renderer/pages/tools/ToolSection.tsx @@ -0,0 +1,189 @@ +// src/renderer/pages/tools/ToolSection.tsx +// 도구 화면의 구획·목록 행·탭 모양 — 카드·글로우 없이 제목(h2)과 1px 선으로 나눈다(홈과 같은 문법). +// 접히는 구획은 머리 전체가 버튼(aria-expanded)이다. + +import React, { useId, useState } from 'react' +import { Alert, Box, Button, ButtonBase, Typography } from '@mui/material' +import { ChevronRight } from 'lucide-react' +import { d3roPalette, d3roRadius, d3roFocusRing, d3roTransition, typoSx } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' + +/** 도구 화면 본문 폭 — 홈과 같다 */ +export const TOOL_PAGE_SX = { maxWidth: 1000, mx: 'auto', px: 5, py: 4 } as const + +interface ToolSectionProps { + title: string + /** 제목 옆 개수 */ + count?: number + /** 제목 아래 한 줄 설명 */ + description?: React.ReactNode + /** 오른쪽 행동 (접히는 구획이면 펼쳤을 때만 보인다) */ + action?: React.ReactNode + /** 접을 수 있는 구획 */ + collapsible?: boolean + defaultOpen?: boolean + children: React.ReactNode + sx?: object +} + +export function ToolSection({ + title, + count, + description, + action, + collapsible = false, + defaultOpen = true, + children, + sx, +}: ToolSectionProps): React.ReactElement { + const titleId = useId() + const bodyId = useId() + const [open, setOpen] = useState(defaultOpen) + const expanded = !collapsible || open + + const heading = ( + + + {title} + + {count !== undefined && ( + + {count} + + )} + + ) + + return ( + + + {collapsible ? ( + setOpen((v) => !v)} + aria-expanded={open} + aria-controls={bodyId} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + borderRadius: d3roRadius.xs, + pr: 1, + '&:focus-visible': d3roFocusRing, + }} + > + + {heading} + + ) : ( + heading + )} + {expanded && action ? {action} : null} + + {expanded && ( + + {description && ( + {description} + )} + {children} + + )} + + ) +} + +/** 목록 한 줄 — 안에 버튼이 있으므로 행 자체는 버튼이 아니다 */ +export function ToolRow({ children, selected = false }: { children: React.ReactNode; selected?: boolean }): React.ReactElement { + return ( + + {children} + + ) +} + +/** 탭 모양 한 가지 — 지식 베이스 탭과 입력 인사이트 하위 탭이 같은 글자·색을 쓴다 */ +export const TOOL_TABS_SX = { + minHeight: 36, + borderBottom: `1px solid ${d3roPalette.border.subtle}`, + '& .MuiTab-root': { + minHeight: 36, + minWidth: 0, + px: 1.5, + py: 0.5, + textTransform: 'none', + ...typoSx('compact'), + fontWeight: 500, + color: d3roPalette.text.secondary, + '&.Mui-selected': { color: d3roPalette.text.primary }, + '&.Mui-focusVisible': d3roFocusRing, + }, + '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }, +} as const + +/** 목록 줄의 글자 두 단 */ +export const TOOL_TEXT_SX = { + meta: { ...typoSx('small'), color: d3roPalette.text.tertiary }, + secondary: { ...typoSx('compact'), color: d3roPalette.text.secondary }, +} as const + +/** 목록을 읽지 못했을 때 — 무엇이 실패했는지 + 다시 시도 */ +export function ToolLoadError({ message, onRetry }: { message: string; onRetry: () => void }): React.ReactElement { + const { t } = useI18n() + return ( + + {t('common.retry')} + + } + > + {message} + + ) +} + +/** 불러오는 중 한 줄 */ +export function ToolLoading(): React.ReactElement { + const { t } = useI18n() + return ( + + {t('common.loading')} + + ) +} diff --git a/apps/desktop/src/renderer/pages/tools/tools-model.ts b/apps/desktop/src/renderer/pages/tools/tools-model.ts new file mode 100644 index 0000000..59c2b09 --- /dev/null +++ b/apps/desktop/src/renderer/pages/tools/tools-model.ts @@ -0,0 +1,157 @@ +// src/renderer/pages/tools/tools-model.ts +// 2급 "도구" 화면(AI 대화·명령어·사전·지식 베이스)이 함께 쓰는 순수 판단 — React·IPC 의존 없음. +// 화면이 말하는 상태는 여기서 한 번만 정한다: 조회한 설정에서 나온 사실만 쓰고, 추측을 '정상'으로 쓰지 않는다. + +import type { TranslationKey } from '@d3ro/i18n' +import type { ConversationState, LLMAction, LLMActionSelection, LLMProcessParams } from '@d3ro/core/types' + +/** 지연 삭제 중인 항목을 목록에서 뺀다 */ +export function withoutHidden(items: readonly T[], hidden: ReadonlySet): T[] { + if (hidden.size === 0) return [...items] + return items.filter((item) => !hidden.has(item.id)) +} + +// ── 명령어: 받아쓰기 후처리 경로 ───────────────────────────── + +/** 명령어 목록의 "사용 안 함" 선택 값 (지시문 id 와 겹치지 않는다) */ +export const COMMAND_NONE = '__none__' + +interface InstructionLike { + id: string + name: string + prompt: string +} + +/** + * 지금 받아쓰기에 실제로 적용되는 후처리 — main 의 resolveVoiceLlmRoute 와 같은 규칙: + * 지시문은 기본 액션이 'custom' 일 때만 쓰인다(활성 지시문이 있어도 refine 등을 덮지 않는다). + */ +export type CommandPipeline = + | { kind: 'raw' } + | { kind: 'instruction'; instruction: T } + /** 'custom' 인데 활성 지시문이 없거나 지워졌다 */ + | { kind: 'instruction-missing' } + | { kind: 'action'; action: Exclude } + +export function resolveCommandPipeline( + config: { defaultLLMAction: LLMActionSelection; activeInstructionId: string | null }, + instructions: readonly T[], +): CommandPipeline { + const action = config.defaultLLMAction + if (action === 'none') return { kind: 'raw' } + if (action === 'custom') { + const instruction = config.activeInstructionId + ? instructions.find((item) => item.id === config.activeInstructionId) + : undefined + return instruction ? { kind: 'instruction', instruction } : { kind: 'instruction-missing' } + } + return { kind: 'action', action } +} + +/** 목록 라디오의 선택 값 — 일반 액션(다듬기 등)이 기본이면 아무것도 고르지 않은 상태다 */ +export function commandSelection(pipeline: CommandPipeline): string { + if (pipeline.kind === 'raw') return COMMAND_NONE + if (pipeline.kind === 'instruction') return pipeline.instruction.id + return '' +} + +/** 일반 후처리 액션 이름 */ +export function llmActionLabelKey(action: Exclude): TranslationKey { + switch (action) { + case 'refine': + return 'settings.action.refine' + case 'translate': + return 'settings.action.translate' + case 'summarize': + return 'settings.action.summarize' + case 'grammar': + return 'settings.action.grammar' + case 'expand': + return 'tools.commands.action.expand' + case 'chain': + return 'tools.commands.action.chain' + } +} + +/** + * 테스트 실행 요청 — 실제 받아쓰기와 같은 경로를 탄다. 실행할 수 없으면 이유를 돌려준다. + * (예전엔 활성 명령이 없으면 무조건 'refine' 을 돌려, 원문을 그대로 넣는 설정에서도 다듬은 결과를 보여 줬다.) + */ +export function commandTestRequest( + pipeline: CommandPipeline, + text: string, +): { ok: true; params: LLMProcessParams } | { ok: false; reason: 'raw' | 'chain' | 'missing' | 'empty' } { + const trimmed = text.trim() + if (!trimmed) return { ok: false, reason: 'empty' } + switch (pipeline.kind) { + case 'raw': + return { ok: false, reason: 'raw' } + case 'instruction-missing': + return { ok: false, reason: 'missing' } + case 'instruction': + return { ok: true, params: { text: trimmed, action: 'custom', customPrompt: pipeline.instruction.prompt } } + case 'action': + // 체인은 여러 단계를 main 의 ChainService 가 실행한다 — 한 번의 LLM 호출로 흉내 내지 않는다 + if (pipeline.action === 'chain') return { ok: false, reason: 'chain' } + return { ok: true, params: { text: trimmed, action: pipeline.action } } + } +} + +// ── AI 대화: 상태 표지 ───────────────────────────────────── + +export interface ConversationBadge { + labelKey: TranslationKey + tone: 'default' | 'accent' + pulse: boolean +} + +/** + * 머리의 상태 표지. 세션이 꺼져 있으면 아무것도 보이지 않는다 — 켜지지 않은 대화를 '대기'로 쓰지 않는다. + * 강조색은 듣는 중(녹음)일 때만. + */ +export function conversationBadge(input: { + active: boolean + state: ConversationState + connecting: boolean +}): ConversationBadge | null { + if (input.connecting) return { labelKey: 'conversation.realtime.connecting', tone: 'default', pulse: true } + if (!input.active) return null + switch (input.state) { + case 'listening': + return { labelKey: 'conversation.listening', tone: 'accent', pulse: true } + case 'thinking': + return { labelKey: 'conversation.thinking', tone: 'default', pulse: true } + case 'speaking': + return { labelKey: 'conversation.speaking', tone: 'default', pulse: true } + case 'idle': + return { labelKey: 'conversation.idle', tone: 'default', pulse: false } + } +} + +// ── 지식 베이스 ──────────────────────────────────────────── + +/** 질문 예시 — 누르면 그 문장으로 바로 질문한다 */ +export const KNOWLEDGE_EXAMPLE_KEYS: readonly TranslationKey[] = [ + 'tools.knowledge.example.summary', + 'tools.knowledge.example.schedule', + 'tools.knowledge.example.policy', +] + +/** 문서 색인 상태 — 진행 중인 문서만 '색인 중', 끝났는데 색인이 없으면 '색인 안 됨'(대기 중이라 쓰지 않는다) */ +export function documentIndexLabelKey(doc: { id: string; indexed: boolean }, indexingId: string | null): TranslationKey { + if (doc.id === indexingId) return 'rag.indexing' + return doc.indexed ? 'rag.indexed' : 'tools.knowledge.notIndexed' +} + +// ── 사전 ────────────────────────────────────────────────── + +export function dictionaryCategoryLabelKey(category: 'user' | 'auto' | 'technical'): TranslationKey { + switch (category) { + case 'user': + return 'mobile.dictionary.category.user' + case 'auto': + return 'mobile.dictionary.category.auto' + case 'technical': + return 'mobile.dictionary.category.technical' + } +} diff --git a/apps/desktop/src/renderer/pages/tools/useDeferredDelete.ts b/apps/desktop/src/renderer/pages/tools/useDeferredDelete.ts new file mode 100644 index 0000000..4b0fe68 --- /dev/null +++ b/apps/desktop/src/renderer/pages/tools/useDeferredDelete.ts @@ -0,0 +1,64 @@ +// src/renderer/pages/tools/useDeferredDelete.ts +// 한 건 삭제의 "삭제했습니다 · 되돌리기" — 목록에서 바로 숨기고, 알림이 시간 초과로 닫힐 때 실제로 지운다. +// 되돌리기를 누르면 다시 보인다. 실제 삭제가 실패하면 항목을 되살리고 오류를 알린다. + +import { useCallback, useState } from 'react' +import { useI18n } from '@d3ro/i18n' +import { useNotify } from '../../components/shared' + +export interface DeferredDeleteRequest { + id: string + /** 알림 문구 (기본: "삭제했습니다.") */ + message?: string + /** 실제 삭제. 성공하면 true */ + commit: () => Promise + /** 실제 삭제가 끝난 뒤 (목록 다시 읽기 등) */ + onCommitted?: () => void +} + +export interface DeferredDelete { + /** 삭제 대기 중이라 숨긴 id */ + hidden: ReadonlySet + remove: (request: DeferredDeleteRequest) => void +} + +export function useDeferredDelete(): DeferredDelete { + const { t } = useI18n() + const notify = useNotify() + const [hidden, setHidden] = useState>(() => new Set()) + + const reveal = useCallback((id: string) => { + setHidden((prev) => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + }, []) + + const remove = useCallback( + (request: DeferredDeleteRequest) => { + setHidden((prev) => new Set(prev).add(request.id)) + notify({ + message: request.message ?? t('common.deleted'), + action: { label: t('common.undo'), onClick: () => reveal(request.id) }, + onExpire: () => { + void request + .commit() + .catch(() => false) + .then((ok) => { + if (ok) { + request.onCommitted?.() + return + } + reveal(request.id) + notify({ message: t('tools.deleteFailed'), severity: 'error' }) + }) + }, + }) + }, + [notify, reveal, t], + ) + + return { hidden, remove } +} diff --git a/apps/desktop/src/renderer/utils/display-labels.ts b/apps/desktop/src/renderer/utils/display-labels.ts new file mode 100644 index 0000000..8142c25 --- /dev/null +++ b/apps/desktop/src/renderer/utils/display-labels.ts @@ -0,0 +1,56 @@ +// src/renderer/utils/display-labels.ts +// 도메인 값 → 번역 키 매핑의 SSOT. `t(\`license.feature.${x}\`)` 처럼 키를 문자열로 조립하면 +// 키가 빠져도 타입 검사가 잡지 못하고 화면에 키 문자열이 그대로 나온다(2026-09-28 실측: premium_llm). + +import type { TranslationKey } from '@d3ro/i18n' +import type { Feature, HistoryEntry, LicenseTier } from '@d3ro/core/types' + +const FEATURE_LABEL: Record<`${Feature}`, TranslationKey> = { + dictation: 'license.feature.dictation', + llm_process: 'license.feature.llm_process', + history_unlimited: 'license.feature.history_unlimited', + history_export: 'license.feature.history_export', + custom_instruction_create: 'license.feature.custom_instruction_create', + live_caption: 'license.feature.live_caption', + screen_context: 'license.feature.screen_context', + voice_memo: 'license.feature.voice_memo', + voice_command: 'license.feature.voice_command', + llm_chain: 'license.feature.llm_chain', + file_transcription: 'license.feature.file_transcription', + voice_conversation: 'license.feature.voice_conversation', + dictation_template: 'license.feature.dictation_template', + meeting_summary: 'license.feature.meeting_summary', + local_rag: 'license.feature.local_rag', + os_automation: 'license.feature.os_automation', + premium_llm: 'license.feature.premium_llm', + cloud_sync: 'license.feature.cloud_sync', + team_workspace: 'license.feature.team_workspace', +} + +export function featureLabelKey(feature: Feature | `${Feature}`): TranslationKey { + return FEATURE_LABEL[feature] +} + +const TIER_LABEL: Record = { + free: 'license.free', + pro: 'license.pro', + pro_plus: 'license.proPlus', + team: 'license.team', + enterprise: 'license.enterprise', +} + +export function tierLabelKey(tier: LicenseTier): TranslationKey { + return TIER_LABEL[tier] +} + +const RECORD_MODE_LABEL: Record = { + dictation: 'record.mode.dictation', + translate: 'record.mode.translate', + command: 'record.mode.command', + caption: 'record.mode.caption', + 'file-transcription': 'record.mode.file', +} + +export function recordModeLabelKey(mode: HistoryEntry['mode']): TranslationKey { + return RECORD_MODE_LABEL[mode] +} diff --git a/apps/desktop/src/renderer/utils/formatters.ts b/apps/desktop/src/renderer/utils/formatters.ts index 027bd1f..ebb327b 100644 --- a/apps/desktop/src/renderer/utils/formatters.ts +++ b/apps/desktop/src/renderer/utils/formatters.ts @@ -37,3 +37,27 @@ export function formatRecordingTimeUnit(ms: number): string { const totalMin = Math.round(ms / 60000) return totalMin >= 60 ? 'HR' : 'MIN' } + +/** + * ms → 로케일 단위가 붙은 길이 ("12분", "1시간 5분" / "12 min", "1 hr 5 min"). + * "MIN"/"HR" 영문 대문자 단위를 한국어 화면에 그대로 찍던 문제를 대신한다(2026-09-28). + */ +export function formatDurationLong(ms: number, locale: string): string { + const totalMin = Math.max(0, Math.round(ms / 60000)) + const unit = (value: number, u: 'hour' | 'minute'): string => + new Intl.NumberFormat(locale, { style: 'unit', unit: u, unitDisplay: 'short' }).format(value) + if (totalMin < 60) return unit(totalMin, 'minute') + const h = Math.floor(totalMin / 60) + const m = totalMin % 60 + return m === 0 ? unit(h, 'hour') : `${unit(h, 'hour')} ${unit(m, 'minute')}` +} + +/** 타임스탬프 → 로케일 시:분 ("오후 3:04" / "3:04 PM") */ +export function formatClockTime(ts: number, locale: string): string { + return new Date(ts).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }) +} + +/** 타임스탬프 → 로케일 날짜 ("9월 28일 (일)" / "Sun, Sep 28") */ +export function formatDayLabel(ts: number, locale: string): string { + return new Date(ts).toLocaleDateString(locale, { month: 'long', day: 'numeric', weekday: 'short' }) +} diff --git a/apps/desktop/src/renderer/utils/systemAudioCapture.ts b/apps/desktop/src/renderer/utils/systemAudioCapture.ts index 3636079..5e46268 100644 --- a/apps/desktop/src/renderer/utils/systemAudioCapture.ts +++ b/apps/desktop/src/renderer/utils/systemAudioCapture.ts @@ -1,98 +1,102 @@ // src/renderer/utils/systemAudioCapture.ts // 시스템 오디오(데스크톱 소리) 캡처 — Electron setDisplayMediaRequestHandler + audio: 'loopback' -// contextIsolation: true 환경에서 preload IPC 브릿지를 통해 동작 +// contextIsolation: true 환경에서 preload IPC 브릿지를 통해 동작. +// +// 이 파일은 브라우저/IPC 어댑터(SystemAudioPort 구현)만 담는다. +// 수명주기(idle/starting/active, stop-during-start 취소)는 SystemAudioCaptureController 담당. -let mediaStream: MediaStream | null = null -let audioContext: AudioContext | null = null -let processorNode: ScriptProcessorNode | null = null +import { + SystemAudioCaptureController, + type PcmPipeline, + type SystemAudioCaptureOptions, + type SystemAudioPort, + type SystemAudioStream, +} from './systemAudioCaptureController' const TARGET_SAMPLE_RATE = 16000 +const PROCESSOR_BUFFER_SIZE = 4096 -/** - * 시스템 오디오 캡처를 시작한다. - * 1. preload를 통해 메인 프로세스에 loopback 핸들러 등록 요청 - * 2. getDisplayMedia로 시스템 오디오 MediaStream 획득 - * 3. ScriptProcessorNode로 PCM16 16kHz mono 변환 후 콜백 전달 - */ -export async function startSystemAudioCapture( - onAudioData: (pcm16Buffer: ArrayBuffer) => void, -): Promise { - if (mediaStream) { - throw new Error('System audio capture already active') +/** Float32 [-1, 1] 샘플을 PCM16 little-endian 버퍼로 변환한다. */ +export function float32ToPcm16(input: Float32Array): ArrayBuffer { + const pcm16 = new Int16Array(input.length) + for (let i = 0; i < input.length; i++) { + const s = Math.max(-1, Math.min(1, input[i])) + pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff + } + return pcm16.buffer +} + +export class BrowserSystemAudioStream implements SystemAudioStream { + constructor(readonly mediaStream: MediaStream) {} + + dropVideoTracks(): void { + this.mediaStream.getVideoTracks().forEach((t) => { + t.stop() + this.mediaStream.removeTrack(t) + }) } - // 1. 메인 프로세스에 loopback 핸들러 등록 - await window.electronAPI.caption.enableLoopback() + hasAudioTrack(): boolean { + return this.mediaStream.getAudioTracks().length > 0 + } - // 2. getDisplayMedia — video: true 필수 (Chromium 제약), 이후 video track 제거 + stop(): void { + this.mediaStream.getTracks().forEach((t) => t.stop()) + } +} + +function createBrowserPcmPipeline( + stream: BrowserSystemAudioStream, + onPcm: (pcm16: ArrayBuffer) => void, +): PcmPipeline { + const audioTrack = stream.mediaStream.getAudioTracks()[0] + if (!audioTrack) throw new Error('No audio track in loopback stream') + + const audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE }) try { - mediaStream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: true, - }) + const source = audioContext.createMediaStreamSource(new MediaStream([audioTrack])) + const processorNode = audioContext.createScriptProcessor(PROCESSOR_BUFFER_SIZE, 1, 1) + processorNode.onaudioprocess = (event: AudioProcessingEvent) => { + onPcm(float32ToPcm16(event.inputBuffer.getChannelData(0))) + } + source.connect(processorNode) + processorNode.connect(audioContext.destination) + + return { + close: () => { + processorNode.onaudioprocess = null + processorNode.disconnect() + source.disconnect() + audioContext.close().catch(() => { /* ignore */ }) + }, + } } catch (err) { - // loopback 핸들러 해제 - await window.electronAPI.caption.disableLoopback() + audioContext.close().catch(() => { /* ignore */ }) throw err } - - // 3. loopback 핸들러 해제 (다른 getDisplayMedia 호출에 영향 방지) - await window.electronAPI.caption.disableLoopback() - - // 4. 비디오 트랙 제거 - mediaStream.getVideoTracks().forEach((t) => { - t.stop() - mediaStream?.removeTrack(t) - }) - - const audioTrack = mediaStream.getAudioTracks()[0] - if (!audioTrack) { - stopSystemAudioCapture() - throw new Error('No audio track in loopback stream') - } - - // 5. AudioContext로 PCM 추출 - audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE }) - const source = audioContext.createMediaStreamSource(new MediaStream([audioTrack])) - - const bufferSize = 4096 - processorNode = audioContext.createScriptProcessor(bufferSize, 1, 1) - - processorNode.onaudioprocess = (event: AudioProcessingEvent) => { - const inputData = event.inputBuffer.getChannelData(0) - - // Float32 → PCM16 변환 - const pcm16 = new Int16Array(inputData.length) - for (let i = 0; i < inputData.length; i++) { - const s = Math.max(-1, Math.min(1, inputData[i])) - pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff - } - - onAudioData(pcm16.buffer) - } - - source.connect(processorNode) - processorNode.connect(audioContext.destination) } -export function stopSystemAudioCapture(): void { - if (processorNode) { - processorNode.disconnect() - processorNode.onaudioprocess = null - processorNode = null - } - - if (audioContext) { - audioContext.close().catch(() => { /* ignore */ }) - audioContext = null - } - - if (mediaStream) { - mediaStream.getTracks().forEach((t) => t.stop()) - mediaStream = null +/** preload 브릿지 + getDisplayMedia + Web Audio 기반 SystemAudioPort */ +export function createBrowserSystemAudioPort(): SystemAudioPort { + return { + enableLoopback: async () => { + await window.electronAPI.caption.enableLoopback() + }, + disableLoopback: async () => { + await window.electronAPI.caption.disableLoopback() + }, + // video: true 필수 (Chromium 제약) — 이후 컨트롤러가 video track 제거 + getDisplayMedia: async () => + new BrowserSystemAudioStream( + await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }), + ), + createPcmPipeline: createBrowserPcmPipeline, } } -export function isSystemAudioCaptureActive(): boolean { - return mediaStream !== null +/** 브라우저 어댑터에 묶인 시스템 오디오 캡처 컨트롤러를 만든다. */ +export function createSystemAudioCaptureController( + options: SystemAudioCaptureOptions, +): SystemAudioCaptureController { + return new SystemAudioCaptureController(createBrowserSystemAudioPort(), options) } diff --git a/apps/desktop/src/renderer/utils/systemAudioCaptureController.ts b/apps/desktop/src/renderer/utils/systemAudioCaptureController.ts new file mode 100644 index 0000000..19f6bf9 --- /dev/null +++ b/apps/desktop/src/renderer/utils/systemAudioCaptureController.ts @@ -0,0 +1,181 @@ +// src/renderer/utils/systemAudioCaptureController.ts +// 시스템 오디오 캡처 수명주기 상태머신 (idle / starting / active + generation 토큰). +// 브라우저/IPC 세부사항은 SystemAudioPort 뒤로 숨긴다 → DOM 없이 Node에서 테스트 가능. +// +// 보장: +// - starting 중 stop()이 오면 generation이 증가하고, 진행 중인 starter는 각 await 직후 +// 이를 확인해 자기가 획득한 자원(loopback 핸들러, 스트림, 파이프라인)을 스스로 해제한다. +// - starting 중 start()는 진행 중인 promise를 그대로 돌려준다 (중복 획득 금지). +// - stop 뒤의 새 start는 이전 starter가 완전히 정리될 때까지 기다린다 +// (이전 starter의 disableLoopback이 새 요청의 loopback 핸들러를 지우는 경합 방지). +// - stop()은 멱등이다 (메인은 시작 요청을 보낸 적이 없어도 stop을 보낸다). + +/** 캡처된 루프백 스트림 — 컨트롤러는 내용물을 알 필요가 없다. */ +export interface SystemAudioStream { + /** 비디오 트랙을 정지·제거한다 (getDisplayMedia는 video: true가 필수라 받아온 것). */ + dropVideoTracks(): void + hasAudioTrack(): boolean + /** 모든 트랙 정지 */ + stop(): void +} + +/** PCM16 16kHz mono 변환 파이프라인 핸들 */ +export interface PcmPipeline { + close(): void +} + +/** 캡처에 필요한 IO 포트 (브라우저 어댑터는 systemAudioCapture.ts) */ +export interface SystemAudioPort { + enableLoopback(): Promise + disableLoopback(): Promise + getDisplayMedia(): Promise + createPcmPipeline(stream: S, onPcm: (pcm16: ArrayBuffer) => void): PcmPipeline +} + +export type SystemAudioCaptureState = 'idle' | 'starting' | 'active' + +export interface SystemAudioCaptureOptions { + onPcm: (pcm16: ArrayBuffer) => void + /** 정리 단계의 부수 실패(해제 실패 등) 보고용. 기본은 무시. */ + onCleanupError?: (err: unknown) => void +} + +interface ActiveSession { + stream: S + pipeline: PcmPipeline +} + +export class SystemAudioCaptureController { + private state: SystemAudioCaptureState = 'idle' + private generation = 0 + private pending: Promise | null = null + /** 마지막 starter가 끝날 때까지(성공/실패/취소 무관) 기다리는 꼬리 */ + private tail: Promise = Promise.resolve() + private session: ActiveSession | null = null + + constructor( + private readonly port: SystemAudioPort, + private readonly options: SystemAudioCaptureOptions, + ) {} + + getState(): SystemAudioCaptureState { + return this.state + } + + isActive(): boolean { + return this.state === 'active' + } + + /** + * 캡처 시작. 이미 active면 no-op, starting이면 진행 중인 promise를 반환한다. + * 도중에 stop()으로 취소되면 reject 없이 resolve한다. + */ + start(): Promise { + if (this.state === 'active') return Promise.resolve() + if (this.state === 'starting' && this.pending) return this.pending + + const gen = ++this.generation + this.state = 'starting' + const run = this.tail.then(() => this.runStart(gen)) + this.tail = run.then( + () => undefined, + () => undefined, + ) + this.pending = run + return run + } + + /** 캡처 중지. 멱등. starting 중이면 진행 중인 starter를 취소한다. */ + stop(): void { + this.generation++ + this.state = 'idle' + this.pending = null + const session = this.session + this.session = null + if (session) this.closeSession(session) + } + + private isCurrent(gen: number): boolean { + return gen === this.generation + } + + private async runStart(gen: number): Promise { + try { + if (!this.isCurrent(gen)) return + + // 1. 메인 프로세스에 loopback 핸들러 등록 + await this.port.enableLoopback() + if (!this.isCurrent(gen)) { + await this.safeDisableLoopback() + return + } + + // 2. getDisplayMedia — 실패해도 loopback 핸들러는 반드시 해제 + let stream: S + try { + stream = await this.port.getDisplayMedia() + } finally { + // 3. loopback 핸들러 해제 (다른 getDisplayMedia 호출에 영향 방지) + await this.safeDisableLoopback() + } + if (!this.isCurrent(gen)) { + this.safeStopStream(stream) + return + } + + // 4. 비디오 트랙 제거 + 오디오 트랙 확인 + stream.dropVideoTracks() + if (!stream.hasAudioTrack()) { + this.safeStopStream(stream) + throw new Error('No audio track in loopback stream') + } + + // 5. PCM 파이프라인 구성 (동기 — 이후 await 없음) + let pipeline: PcmPipeline + try { + pipeline = this.port.createPcmPipeline(stream, this.options.onPcm) + } catch (err) { + this.safeStopStream(stream) + throw err + } + + this.session = { stream, pipeline } + this.state = 'active' + this.pending = null + } catch (err) { + if (!this.isCurrent(gen)) return // 취소된 starter의 실패는 호출자에게 의미 없음 + this.state = 'idle' + this.pending = null + throw err + } + } + + private closeSession(session: ActiveSession): void { + try { + session.pipeline.close() + } catch (err) { + this.reportCleanupError(err) + } + this.safeStopStream(session.stream) + } + + private safeStopStream(stream: SystemAudioStream): void { + try { + stream.stop() + } catch (err) { + this.reportCleanupError(err) + } + } + + private async safeDisableLoopback(): Promise { + try { + await this.port.disableLoopback() + } catch (err) { + this.reportCleanupError(err) + } + } + + private reportCleanupError(err: unknown): void { + this.options.onCleanupError?.(err) + } +} diff --git a/apps/desktop/tests/e2e/history.spec.ts b/apps/desktop/tests/e2e/history.spec.ts index a8e41b1..855a92b 100644 --- a/apps/desktop/tests/e2e/history.spec.ts +++ b/apps/desktop/tests/e2e/history.spec.ts @@ -59,20 +59,23 @@ test.describe('History Page E2E', () => { }); test('should clear all history', async () => { - // Wait for Dashboard to load first - await expect(window.locator('text=/^(Dashboard|대시보드)$/').first()).toBeVisible({ timeout: 10000 }); + // Navigate to Records (sidebar label "기록"/"Records") + await window.locator('text=/^(Records|기록)$/').first().click(); + await expect(window.getByRole('heading', { level: 1, name: /^(Records|기록)$/ })).toBeVisible(); - // Navigate to History Tab - await window.locator('text=/^(History|히스토리|기록)$/').first().click(); - await expect(window.locator('text=/^(History|히스토리|변환 기록)$/').first()).toBeVisible(); - - // Look for a Clear All button (this should fail in TDD RED) + // 전체 삭제는 더보기 메뉴 안의 위험 행동 → 확인 대화상자를 거친다 + await window.getByTestId('records-more-button').click(); const clearButton = window.getByTestId('clear-all-history-button'); await expect(clearButton).toBeVisible(); - - // Click it and confirm await clearButton.click(); + // 기록이 있으면 확인 대화상자가 뜬다 — "모두 삭제"로 확정 + const confirm = window.getByRole('button', { name: /^(Delete all|모두 삭제)$/ }); + await confirm + .waitFor({ state: 'visible', timeout: 3000 }) + .then(() => confirm.click()) + .catch(() => undefined); + // Expect empty state to appear await expect(window.getByTestId('empty-state-card')).toBeVisible(); }); diff --git a/apps/desktop/tests/e2e/launch_readiness.spec.ts b/apps/desktop/tests/e2e/launch_readiness.spec.ts index 75c12ff..5a71785 100644 --- a/apps/desktop/tests/e2e/launch_readiness.spec.ts +++ b/apps/desktop/tests/e2e/launch_readiness.spec.ts @@ -122,7 +122,7 @@ test.describe('Launch Readiness E2E & Visual Verification', () => { test('03. Should expose the web billing upgrade link without an in-app checkout modal', async () => { // "Remove ads with Pro" opens billingUrl({ tier }) in the external browser (LICENSE.OPEN_BILLING). - const removeAdsLink = window.locator('text=/Remove ads with Pro/').first(); + const removeAdsLink = window.locator('text=/Remove ads with Pro|Pro로 광고 없애기/').first(); if (await removeAdsLink.isVisible()) { await expect(window.locator('text=/D3RO Voice Secure Checkout/')).toHaveCount(0); } diff --git a/apps/desktop/tests/e2e/red_team_cycle5.spec.ts b/apps/desktop/tests/e2e/red_team_cycle5.spec.ts index cf87520..d060c76 100644 --- a/apps/desktop/tests/e2e/red_team_cycle5.spec.ts +++ b/apps/desktop/tests/e2e/red_team_cycle5.spec.ts @@ -138,47 +138,47 @@ test.describe.serial('Extreme Red Team - Cycle 5: Rapid Route Thrashing, Onboard await window.waitForTimeout(1000); // 2. Verify OnboardingModal is visible - await expect(window.getByText(/D3RO Voice 시작 가이드/i)).toBeVisible({ timeout: 10000 }); - await expect(window.getByText(/로컬 AI 모드/i).first()).toBeVisible(); - await expect(window.getByText(/온라인 클라우드 모드/i).first()).toBeVisible(); + await expect(window.getByText(/D3RO Voice 시작하기/i)).toBeVisible({ timeout: 10000 }); + await expect(window.getByText(/로컬 모드/i).first()).toBeVisible(); + await expect(window.getByText(/온라인 모드/i).first()).toBeVisible(); await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt17_01_onboarding_select_mode.png'), }); // 3. Test Local Mode Selection - await window.getByRole('button', { name: /로컬 AI 모드로 시작/i }).click(); + await window.getByRole('button', { name: /로컬 모드로 시작/i }).click(); await window.waitForTimeout(600); // Verify transitions to local_ollama_setup phase - await expect(window.getByText(/Ollama 로컬 엔진/i).first()).toBeVisible({ timeout: 5000 }); + await expect(window.getByText(/Ollama에 연결/i).first()).toBeVisible({ timeout: 5000 }); await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt17_02_onboarding_local_setup.png'), }); // Test back button - const backBtn = window.getByText('뒤로 가기', { exact: true }); + const backBtn = window.getByRole('button', { name: '뒤로', exact: true }); await expect(backBtn).toBeVisible(); await backBtn.click(); await window.waitForTimeout(500); // Verify back to select_mode - await expect(window.getByText(/로컬 AI 모드/i).first()).toBeVisible(); + await expect(window.getByText(/로컬 모드/i).first()).toBeVisible(); // 4. Test Online Cloud Mode Selection - await window.getByRole('button', { name: /온라인 계정 로그인/i }).click(); + await window.getByRole('button', { name: /계정으로 로그인/i }).click(); await window.waitForTimeout(600); // Verify transitions to online_auth phase - await expect(window.getByText(/온라인 서비스를 이용하기 위해/i).first()).toBeVisible({ timeout: 5000 }); + await expect(window.getByText(/D3RO 계정으로 로그인하면 온라인 모드로/i).first()).toBeVisible({ timeout: 5000 }); await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt17_03_onboarding_online_auth.png'), }); // Go back again - await window.getByText('뒤로 가기', { exact: true }).click(); + await window.getByRole('button', { name: '뒤로', exact: true }).click(); await window.waitForTimeout(500); // 5. Restore onboardingCompleted diff --git a/apps/desktop/tests/unit/meeting-documents-redteam-r3-25.test.ts b/apps/desktop/tests/unit/meeting-documents-redteam-r3-25.test.ts new file mode 100644 index 0000000..f7c61d7 --- /dev/null +++ b/apps/desktop/tests/unit/meeting-documents-redteam-r3-25.test.ts @@ -0,0 +1,335 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MeetingDocument } from '@d3ro/core/types' +import { + MEETING_DETAIL_TABS, + MeetingDocumentsStore, + extractActionItems, + meetingDocumentsPortFromApi, + resolveSelectedDocumentId, + selectPrimaryActionItems, + toggleActionItem, + withoutChecklistLines, + type DocumentWriteOutcome, + type MeetingDocumentsPort, +} from '../../src/renderer/components/meeting/meeting-documents-model' + +function doc(id: string, content: string): MeetingDocument { + return { + id, + sessionId: 's1', + templateType: 'summary' as MeetingDocument['templateType'], + title: id, + content, + promptUsed: null, + llmModel: null, + llmLatencyMs: null, + createdAt: 0, + updatedAt: 0, + } +} + +function fakePort(overrides: Partial = {}): { + port: MeetingDocumentsPort + saveContent: ReturnType> + remove: ReturnType> +} { + const saveContent = vi.fn( + overrides.saveContent ?? (async (): Promise => ({ ok: true })), + ) + const remove = vi.fn( + overrides.remove ?? (async (): Promise => ({ ok: true })), + ) + return { port: { saveContent, remove }, saveContent, remove } +} + +const CHECKLIST = [ + '# 회의록', + '', + '- [ ] 예산 재검토', + '본문 문단', + ' * [X] 들여쓴 별표 항목', + '-[ ] 붙여 쓴 항목', + '- [x] 완료 항목', +].join('\n') + +describe('extractActionItems — 소스 줄 번호를 보존한다', () => { + it('들여쓰기·* 불릿·[X]·붙여 쓴 괄호를 모두 인식하고 줄 번호를 단다', () => { + expect(extractActionItems(CHECKLIST)).toEqual([ + { id: 'todo-2', lineIndex: 2, text: '예산 재검토', done: false }, + { id: 'todo-4', lineIndex: 4, text: '들여쓴 별표 항목', done: true }, + { id: 'todo-5', lineIndex: 5, text: '붙여 쓴 항목', done: false }, + { id: 'todo-6', lineIndex: 6, text: '완료 항목', done: true }, + ]) + }) + + it('CRLF 본문도 인식한다', () => { + expect(extractActionItems('- [ ] a\r\n- [x] b\r\n')).toEqual([ + { id: 'todo-0', lineIndex: 0, text: 'a', done: false }, + { id: 'todo-1', lineIndex: 1, text: 'b', done: true }, + ]) + }) +}) + +describe('toggleActionItem — 목록 위치가 아니라 소스 줄을 고친다', () => { + it('해당 줄의 체크 표시 한 글자만 바꾼다', () => { + const next = toggleActionItem(CHECKLIST, 2, true) + expect(next).toBe(CHECKLIST.replace('- [ ] 예산 재검토', '- [x] 예산 재검토')) + }) + + it('들여쓴 * [X] 를 해제하면 들여쓰기와 불릿은 그대로다', () => { + const next = toggleActionItem(CHECKLIST, 4, false) + expect(next?.split('\n')[4]).toBe(' * [ ] 들여쓴 별표 항목') + expect(next?.split('\n').filter((_, i) => i !== 4)).toEqual(CHECKLIST.split('\n').filter((_, i) => i !== 4)) + }) + + it('CRLF 를 보존한다', () => { + expect(toggleActionItem('- [ ] a\r\n- [ ] b', 1, true)).toBe('- [ ] a\r\n- [x] b') + }) + + it('체크리스트가 아닌 줄·범위 밖·같은 상태면 null', () => { + expect(toggleActionItem(CHECKLIST, 3, true)).toBeNull() + expect(toggleActionItem(CHECKLIST, 99, true)).toBeNull() + expect(toggleActionItem(CHECKLIST, 6, true)).toBeNull() + }) +}) + +describe('selectPrimaryActionItems — 이전 목록을 남기지 않는다', () => { + it('1순위 문서에 체크리스트가 없거나 문서가 없으면 빈 목록', () => { + expect(selectPrimaryActionItems([doc('a', '본문만'), doc('b', '- [ ] x')])).toEqual([]) + expect(selectPrimaryActionItems([])).toEqual([]) + }) +}) + +describe('상세 탭 · 문서 선택 정합', () => { + it('요약이 첫 탭이다(요약 먼저, 전사는 근거)', () => { + expect(MEETING_DETAIL_TABS[0]).toBe('summary') + expect(MEETING_DETAIL_TABS).toEqual(['summary', 'transcript', 'documents', 'notes']) + }) + + it('사라진 문서를 고르고 있으면 첫 문서, 문서가 없으면 null', () => { + const docs = [doc('a', ''), doc('b', '')] + expect(resolveSelectedDocumentId('b', docs)).toBe('b') + expect(resolveSelectedDocumentId('gone', docs)).toBe('a') + expect(resolveSelectedDocumentId(null, docs)).toBe('a') + expect(resolveSelectedDocumentId('gone', [])).toBeNull() + }) +}) + +describe('withoutChecklistLines — 요약 본문에서 할 일 줄만 뺀다(표시 전용)', () => { + it('체크리스트 줄만 빠지고 나머지 줄은 그대로', () => { + expect(withoutChecklistLines('# 요약\n- [ ] 할 일\n본문\n- [x] 끝남\n- 일반 목록')).toBe('# 요약\n본문\n- 일반 목록') + }) +}) + +describe('MeetingDocumentsStore', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('편집은 즉시 반영하고 저장은 문서별로 디바운스한다(A 의 대기 저장이 B 입력에 취소되지 않는다)', async () => { + const { port, saveContent } = fakePort() + const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500) + + store.editContent('a', 'A1') + expect(store.getState().documents[0].content).toBe('A1') + await vi.advanceTimersByTimeAsync(200) + store.editContent('b', 'B1') + await vi.advanceTimersByTimeAsync(600) + + expect(saveContent.mock.calls).toEqual([ + ['a', 'A1'], + ['b', 'B1'], + ]) + }) + + it('연속 입력은 마지막 본문 한 번만 저장한다', async () => { + const { port, saveContent } = fakePort() + const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) + store.editContent('a', 'A1') + await vi.advanceTimersByTimeAsync(300) + store.editContent('a', 'A12') + await vi.advanceTimersByTimeAsync(600) + expect(saveContent.mock.calls).toEqual([['a', 'A12']]) + }) + + it('flush 는 대기 중인 저장을 바로 실행한다(탭을 떠날 때)', async () => { + const { port, saveContent } = fakePort() + const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) + store.editContent('a', 'A1') + await store.flush('a') + expect(saveContent.mock.calls).toEqual([['a', 'A1']]) + await vi.advanceTimersByTimeAsync(1000) + expect(saveContent).toHaveBeenCalledTimes(1) + }) + + it('flushAll 은 모든 문서의 대기 저장을 실행한다(화면을 떠날 때)', async () => { + const { port, saveContent } = fakePort() + const store = new MeetingDocumentsStore([doc('a', 'A0'), doc('b', 'B0')], port, 500) + store.editContent('a', 'A1') + store.editContent('b', 'B1') + await store.flushAll() + expect(saveContent.mock.calls).toEqual([ + ['a', 'A1'], + ['b', 'B1'], + ]) + }) + + it('자동저장 실패는 error 로 드러나고 입력은 지우지 않는다', async () => { + const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'db locked' }) }) + const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) + store.editContent('a', 'A1') + await vi.advanceTimersByTimeAsync(600) + expect(store.getState().error).toBe('db locked') + expect(store.getState().documents[0].content).toBe('A1') + store.clearError() + expect(store.getState().error).toBeNull() + }) + + it('[회귀] 액션 아이템 체크는 문서 markdown 에 [x] 로 저장되고, 이후 다른 문서의 자동저장에도 유지된다', async () => { + const { port, saveContent } = fakePort() + const primary = '# 할 일\n- [ ] 예산 재검토\n- [ ] 일정 공유' + const store = new MeetingDocumentsStore([doc('a', primary), doc('b', 'B0')], port, 500) + + const item = selectPrimaryActionItems(store.getState().documents)[1] + const result = await store.toggleActionItem('a', item.lineIndex, true) + + expect(result).toEqual({ status: 'saved' }) + expect(saveContent).toHaveBeenLastCalledWith('a', '# 할 일\n- [ ] 예산 재검토\n- [x] 일정 공유') + + store.editContent('b', 'B1') + await vi.advanceTimersByTimeAsync(600) + expect(selectPrimaryActionItems(store.getState().documents).map((i) => i.done)).toEqual([false, true]) + }) + + it('체크는 대기 중인 편집과 함께 최신 본문으로 저장되고, 대기 타이머는 다시 쓰지 않는다', async () => { + const { port, saveContent } = fakePort() + const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500) + store.editContent('a', '- [ ] a\n- [ ] b') + await store.toggleActionItem('a', 1, true) + expect(saveContent.mock.calls).toEqual([['a', '- [ ] a\n- [x] b']]) + await vi.advanceTimersByTimeAsync(1000) + expect(saveContent).toHaveBeenCalledTimes(1) + }) + + it('체크 저장이 실패하면 체크를 되돌리고 error 를 남긴다', async () => { + const { port } = fakePort({ saveContent: async () => ({ ok: false, message: 'nope' }) }) + const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500) + const result = await store.toggleActionItem('a', 0, true) + expect(result).toEqual({ status: 'failed', message: 'nope' }) + expect(store.getState().documents[0].content).toBe('- [ ] a') + expect(store.getState().error).toBe('nope') + }) + + it('포트가 던져도 실패로 처리한다', async () => { + const { port } = fakePort({ + saveContent: async () => { + throw new Error('ipc down') + }, + }) + const store = new MeetingDocumentsStore([doc('a', '- [ ] a')], port, 500) + expect(await store.toggleActionItem('a', 0, true)).toEqual({ status: 'failed', message: 'ipc down' }) + }) + + it('같은 문서의 저장은 순서대로 하나씩 실행된다', async () => { + const resolvers: Array<() => void> = [] + const { port, saveContent } = fakePort({ + saveContent: () => new Promise((resolve) => resolvers.push(() => resolve({ ok: true }))), + }) + const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) + store.editContent('a', 'A1') + await vi.advanceTimersByTimeAsync(600) + store.editContent('a', 'A2') + await vi.advanceTimersByTimeAsync(600) + expect(saveContent).toHaveBeenCalledTimes(1) + resolvers[0]() + await vi.advanceTimersByTimeAsync(0) + expect(saveContent.mock.calls).toEqual([ + ['a', 'A1'], + ['a', 'A2'], + ]) + resolvers[1]() + }) + + it('삭제 실패면 문서를 남기고 error 를 드러낸다', async () => { + const { port } = fakePort({ remove: async () => ({ ok: false, message: 'fk' }) }) + const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) + expect(await store.remove('a')).toEqual({ status: 'failed', message: 'fk' }) + expect(store.getState().documents.map((d) => d.id)).toEqual(['a']) + expect(store.getState().error).toBe('fk') + }) + + it('삭제가 확정되면 목록에서 빼고 대기 중인 자동저장은 쓰지 않는다', async () => { + const { port, saveContent, remove } = fakePort() + const store = new MeetingDocumentsStore([doc('a', '- [ ] a'), doc('b', 'B0')], port, 500) + store.editContent('a', '- [ ] a!') + expect(await store.remove('a')).toEqual({ status: 'saved' }) + await vi.advanceTimersByTimeAsync(1000) + expect(remove).toHaveBeenCalledWith('a') + expect(saveContent).not.toHaveBeenCalled() + expect(store.getState().documents.map((d) => d.id)).toEqual(['b']) + // 1순위 문서가 바뀌면 액션 아이템도 새 1순위에서 파생된다(삭제된 문서 목록이 남지 않는다). + expect(selectPrimaryActionItems(store.getState().documents)).toEqual([]) + }) + + it('add 는 생성된 문서를 끝에 붙이고, 같은 id 는 두 번 붙이지 않는다', () => { + const { port } = fakePort() + const store = new MeetingDocumentsStore([doc('a', '')], port, 500) + store.add(doc('b', 'B')) + store.add(doc('b', 'B')) + expect(store.getState().documents.map((d) => d.id)).toEqual(['a', 'b']) + }) + + it('구독자에게 변경을 알리고, 바뀌지 않으면 스냅샷을 유지한다', () => { + const { port } = fakePort() + const store = new MeetingDocumentsStore([doc('a', 'A0')], port, 500) + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + const before = store.getState() + store.editContent('a', 'A0') + expect(store.getState()).toBe(before) + store.editContent('a', 'A1') + expect(listener).toHaveBeenCalledTimes(1) + unsubscribe() + }) +}) + +describe('meetingDocumentsPortFromApi', () => { + it('IPCResult 를 저장 결과로 옮긴다', async () => { + const port = meetingDocumentsPortFromApi({ + updateDocument: async () => ({ success: true, data: undefined }), + deleteDocument: async () => ({ success: false, error: { code: 1 as never, message: 'gone' } }), + }) + expect(await port.saveContent('a', 'x')).toEqual({ ok: true }) + expect(await port.remove('a')).toEqual({ ok: false, message: 'gone' }) + }) +}) + +describe('회의 상세 문서 구조 회귀', () => { + const dir = path.join(__dirname, '../../src/renderer/components/meeting') + const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8') + const documentTab = fs.readFileSync(path.join(dir, 'DocumentTab.tsx'), 'utf-8') + const editor = fs.readFileSync(path.join(dir, 'MarkdownEditor.tsx'), 'utf-8') + + it('액션 아이템은 별도 상태 사본이 아니라 문서에서 파생한다', () => { + expect(detailTabs).not.toMatch(/setActionItems/) + expect(detailTabs).toMatch(/selectPrimaryActionItems/) + }) + + it('DocumentTab 은 문서마다 key 로 렌더링되고, 자체 디바운스 타이머를 두지 않는다', () => { + expect(detailTabs).toMatch(/ { + expect(editor).not.toMatch(/setDraft/) + }) + + it('문서 저장·삭제 IPC 는 뷰에서 직접 부르지 않는다', () => { + expect(detailTabs).not.toMatch(/meetingMode\.(updateDocument|deleteDocument)/) + }) +}) diff --git a/apps/desktop/tests/unit/meeting-page-models-wave2.test.ts b/apps/desktop/tests/unit/meeting-page-models-wave2.test.ts new file mode 100644 index 0000000..de84486 --- /dev/null +++ b/apps/desktop/tests/unit/meeting-page-models-wave2.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import type { MeetingDocument, MeetingSessionSummary } from '@d3ro/core/types' +import { appendMeetingPage, filterMeetingsByTitle, hasMoreMeetings } from '../../src/renderer/pages/meeting/meeting-list' +import { formatElapsed, shouldShowStatusBadge } from '../../src/renderer/components/meeting/meeting-format' +import { + clearNotepad, + loadNotepad, + notepadStorageKey, + saveNotepad, + type NotepadStorage, +} from '../../src/renderer/components/meeting/meeting-notepad-storage' +import { + formatActionItemsForCopy, + selectSummarySource, + summaryCopyText, +} from '../../src/renderer/components/meeting/meeting-summary-model' + +function session(id: string, title: string | null): MeetingSessionSummary { + return { id, title, status: 'completed', startedAt: 0, endedAt: null, durationMs: null, memoCount: 0 } +} + +function memoryStorage(): NotepadStorage & { data: Map } { + const data = new Map() + return { + data, + getItem: (k) => data.get(k) ?? null, + setItem: (k, v) => void data.set(k, v), + removeItem: (k) => void data.delete(k), + } +} + +describe('회의 목록 모델', () => { + it('다음 페이지를 붙일 때 밀려 온 중복은 버린다', () => { + const merged = appendMeetingPage([session('a', 'A'), session('b', 'B')], [session('b', 'B'), session('c', 'C')]) + expect(merged.map((s) => s.id)).toEqual(['a', 'b', 'c']) + expect(hasMoreMeetings(3, 3)).toBe(false) + expect(hasMoreMeetings(2, 3)).toBe(true) + }) + + it('제목 검색은 대소문자·공백을 무시하고, 제목 없는 회의는 대체 제목으로 찾는다', () => { + const list = [session('a', '주간 Sync 회의'), session('b', null)] + expect(filterMeetingsByTitle(list, 'sync 회의', '무제 회의').map((s) => s.id)).toEqual(['a']) + expect(filterMeetingsByTitle(list, '무제', '무제 회의').map((s) => s.id)).toEqual(['b']) + expect(filterMeetingsByTitle(list, ' ', '무제 회의')).toBe(list) + }) + + it('완료는 배지를 달지 않고 예외 상태만 드러낸다', () => { + expect(shouldShowStatusBadge('completed')).toBe(false) + expect(shouldShowStatusBadge('error')).toBe(true) + expect(formatElapsed(65_000)).toBe('01:05') + expect(formatElapsed(3_725_000)).toBe('1:02:05') + }) +}) + +describe('내 메모 보관 — 닫아도 사라지지 않는다', () => { + it('회의 id 별로 저장·복원하고, 비우거나 회의를 지우면 키를 지운다', () => { + const storage = memoryStorage() + expect(saveNotepad(storage, 's1', '- 예산 재검토')).toBe(true) + expect(loadNotepad(storage, 's1')).toBe('- 예산 재검토') + expect(loadNotepad(storage, 's2')).toBe('') + saveNotepad(storage, 's1', ' ') + expect(storage.data.has(notepadStorageKey('s1'))).toBe(false) + saveNotepad(storage, 's1', 'x') + clearNotepad(storage, 's1') + expect(loadNotepad(storage, 's1')).toBe('') + }) + + it('저장소가 막히면 실패를 알리고 던지지 않는다', () => { + const broken: NotepadStorage = { + getItem: () => { + throw new Error('blocked') + }, + setItem: () => { + throw new Error('quota') + }, + removeItem: () => { + throw new Error('blocked') + }, + } + expect(saveNotepad(broken, 's1', 'x')).toBe(false) + expect(loadNotepad(broken, 's1')).toBe('') + expect(() => clearNotepad(broken, 's1')).not.toThrow() + expect(saveNotepad(null, 's1', 'x')).toBe(false) + }) +}) + +describe('요약 출처 — 1순위 문서 → 동기화된 회의록 → 없음', () => { + const doc = (content: string): MeetingDocument => ({ + id: 'd1', + sessionId: 's1', + templateType: 'minutes', + title: '회의록', + content, + promptUsed: null, + llmModel: null, + llmLatencyMs: null, + createdAt: 0, + updatedAt: 0, + }) + const labels = { decisions: '결정 사항', actionItems: '할 일' } + + it('문서가 있으면 문서가 요약이다', () => { + const source = selectSummarySource([doc('# 요약\n본문')], '다른 회의록', null) + expect(source.kind).toBe('document') + expect(summaryCopyText(source, labels)).toBe('# 요약\n본문') + }) + + it('문서가 없으면 minutesMarkdown, 그다음 구조화된 minutes, 없으면 none', () => { + expect(selectSummarySource([], ' md ', null).kind).toBe('markdown') + const minutes = { summary: '요약', decisions: ['출시 연기'], actionItems: [{ task: '공지', assignee: '민수' }], timeline: [] } + const source = selectSummarySource([], null, minutes) + expect(source.kind).toBe('minutes') + expect(summaryCopyText(source, labels)).toBe('요약\n\n## 결정 사항\n- 출시 연기\n\n## 할 일\n- [ ] 공지 (민수)') + expect(selectSummarySource([], '', null).kind).toBe('none') + expect(summaryCopyText({ kind: 'none' }, labels)).toBe('') + }) + + it('할 일 복사는 체크리스트 형식', () => { + expect(formatActionItemsForCopy([{ text: 'a', done: true }, { text: 'b', done: false }])).toBe('- [x] a\n- [ ] b') + }) +}) diff --git a/apps/desktop/tests/unit/records-view.test.ts b/apps/desktop/tests/unit/records-view.test.ts new file mode 100644 index 0000000..c652d73 --- /dev/null +++ b/apps/desktop/tests/unit/records-view.test.ts @@ -0,0 +1,281 @@ +// 기록 화면(2026-09-28 개편) — 필터 조합, 서버 조회 선택, 날짜 묶음, 미리보기, 페이지 이어 불러오기. +import fs from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' +import type { HistoryEntry, HistoryPage } from '@d3ro/core/types' +import { + EMPTY_RECORDS_FILTER, + applyRecordsFilter, + groupRecordsByDay, + isRecordsFilterActive, + matchesRecordText, + mergeRecords, + recordDayKind, + recordOriginalText, + recordPreview, + recordPrimaryText, + selectRecordsSource, + type RecordsFilter, + type RecordsSource, +} from '../../src/renderer/pages/history/records-view' +import { RecordsListStore, hasMoreRecords, type RecordsPort } from '../../src/renderer/pages/history/records-list-store' + +const DAY = 86_400_000 + +function entry(id: string, over: Partial = {}): HistoryEntry { + return { + id, + title: null, + originalText: `original ${id}`, + polishedText: null, + focusedApp: null, + focusedAppName: null, + focusedAppWindowTitle: null, + mode: 'dictation', + status: 'completed', + errorCode: null, + audioLocalPath: null, + duration: 0, + detectedLanguage: null, + micDevice: null, + wordCount: 0, + sttModel: null, + llmModel: null, + sttLatencyMs: null, + llmLatencyMs: null, + createdAt: 1_000, + updatedAt: 1_000, + appVersion: 'test', + summaryText: null, + isFavorite: false, + ...over, + } +} + +const filter = (over: Partial): RecordsFilter => ({ ...EMPTY_RECORDS_FILTER, ...over }) + +describe('기록 필터 — 검색과 태그를 함께 쓴다', () => { + it('서버 조회는 태그 → 검색 → 전체 순으로 하나만 고른다', () => { + expect(selectRecordsSource(filter({}))).toEqual({ kind: 'all' }) + expect(selectRecordsSource(filter({ query: ' 회의 ' }))).toEqual({ kind: 'search', query: '회의' }) + expect(selectRecordsSource(filter({ query: '회의', tag: 'work' }))).toEqual({ kind: 'tag', tag: 'work' }) + }) + + it('태그로 불러온 목록 위에서 검색어를 다시 거른다 (태그를 골라도 검색이 살아 있다)', () => { + const rows = [ + entry('a', { originalText: '예산 회의' }), + entry('b', { originalText: '점심 메모', polishedText: '점심 약속 정리' }), + entry('c', { originalText: 'x', summaryText: '회의 요약' }), + ] + const result = applyRecordsFilter(rows, filter({ tag: 'work', query: '회의' })) + expect(result.map((e) => e.id)).toEqual(['a', 'c']) + }) + + it('검색만 걸리면 서버가 이미 거른 결과이므로 다시 거르지 않는다', () => { + const rows = [entry('a', { originalText: '다른 필드에서 걸린 결과' })] + expect(applyRecordsFilter(rows, filter({ query: '없는말' }))).toHaveLength(1) + }) + + it('모드 필터: 파일 = file-transcription, 명령·번역 = command + translate', () => { + const rows = [ + entry('d', { mode: 'dictation' }), + entry('f', { mode: 'file-transcription' }), + entry('c', { mode: 'command' }), + entry('t', { mode: 'translate' }), + entry('cap', { mode: 'caption' }), + ] + expect(applyRecordsFilter(rows, filter({ mode: 'file' })).map((e) => e.id)).toEqual(['f']) + expect(applyRecordsFilter(rows, filter({ mode: 'commandTranslate' })).map((e) => e.id)).toEqual(['c', 't']) + expect(applyRecordsFilter(rows, filter({ mode: 'caption' })).map((e) => e.id)).toEqual(['cap']) + expect(applyRecordsFilter(rows, filter({ mode: 'all' }))).toHaveLength(5) + }) + + it('즐겨찾기만 보기와 되돌리기를 기다리는 삭제(hidden)를 함께 적용한다', () => { + const rows = [entry('a', { isFavorite: true }), entry('b', { isFavorite: true }), entry('c')] + expect(applyRecordsFilter(rows, filter({ favoritesOnly: true }), new Set(['b'])).map((e) => e.id)).toEqual(['a']) + }) + + it('필터가 걸렸는지 — 공백 검색어는 필터가 아니다', () => { + expect(isRecordsFilterActive(EMPTY_RECORDS_FILTER)).toBe(false) + expect(isRecordsFilterActive(filter({ query: ' ' }))).toBe(false) + expect(isRecordsFilterActive(filter({ mode: 'caption' }))).toBe(true) + expect(isRecordsFilterActive(filter({ favoritesOnly: true }))).toBe(true) + expect(isRecordsFilterActive(filter({ tag: 'x' }))).toBe(true) + }) + + it('텍스트 검색은 대소문자를 가리지 않는다', () => { + expect(matchesRecordText(entry('a', { title: 'Weekly Sync' }), 'weekly')).toBe(true) + expect(matchesRecordText(entry('a'), 'nothing')).toBe(false) + }) +}) + +describe('기록 목록 — 날짜 묶음 · 미리보기 · 복사 대상', () => { + it('최신순 목록을 로컬 날짜별로 묶는다', () => { + const key = (ts: number) => String(Math.floor(ts / DAY)) + const rows = [entry('a', { createdAt: 2 * DAY + 5 }), entry('b', { createdAt: 2 * DAY + 1 }), entry('c', { createdAt: DAY })] + const groups = groupRecordsByDay(rows, key) + expect(groups.map((g) => g.entries.map((e) => e.id))).toEqual([['a', 'b'], ['c']]) + }) + + it('날짜 라벨 종류: 오늘 / 어제 / 올해 / 그 전', () => { + const now = new Date(2026, 8, 28, 15, 0).getTime() + expect(recordDayKind(new Date(2026, 8, 28, 0, 1).getTime(), now)).toBe('today') + expect(recordDayKind(new Date(2026, 8, 27, 23, 59).getTime(), now)).toBe('yesterday') + expect(recordDayKind(new Date(2026, 0, 2).getTime(), now)).toBe('thisYear') + expect(recordDayKind(new Date(2025, 11, 31).getTime(), now)).toBe('older') + }) + + it('다듬은 글이 있으면 그것을 먼저 쓰고, 원문 복사는 그때만 있다', () => { + const polished = entry('a', { originalText: '음 그러니까 내일', polishedText: '내일 보고합니다.' }) + expect(recordPrimaryText(polished)).toBe('내일 보고합니다.') + expect(recordOriginalText(polished)).toBe('음 그러니까 내일') + const raw = entry('b', { originalText: '원문만' }) + expect(recordPrimaryText(raw)).toBe('원문만') + expect(recordOriginalText(raw)).toBeNull() + expect(recordOriginalText(entry('c', { originalText: '같다', polishedText: '같다' }))).toBeNull() + }) + + it('한 줄 미리보기 — 제목이 있으면 제목 + 본문 첫 줄', () => { + expect(recordPreview(entry('a', { title: '주간 회의', originalText: '\n 첫 줄\n둘째 줄' }))).toEqual({ + title: '주간 회의', + snippet: '첫 줄', + }) + expect(recordPreview(entry('b', { originalText: '첫 줄\n둘째 줄' }))).toEqual({ title: '첫 줄', snippet: null }) + }) + + it('페이지를 이어 붙일 때 같은 id 는 한 번만, 최신순 유지', () => { + const merged = mergeRecords([entry('a', { createdAt: 3 }), entry('b', { createdAt: 2 })], [ + entry('b', { createdAt: 2, isFavorite: true }), + entry('c', { createdAt: 1 }), + ]) + expect(merged.map((e) => e.id)).toEqual(['a', 'b', 'c']) + expect(merged[1].isFavorite).toBe(true) + }) +}) + +function deferred() { + let resolve!: (v: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function pageOf(entries: HistoryEntry[], total: number, page = 0, pageSize = 2): HistoryPage { + return { entries, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } +} + +describe('RecordsListStore — 50건 너머로 이어 불러오기', () => { + const all: RecordsSource = { kind: 'all' } + + it('첫 페이지 뒤 loadMore 로 다음 페이지를 붙이고, 끝에 닿으면 더 없다', async () => { + const calls: Array<[string, number, number]> = [] + const rows = [entry('a', { createdAt: 5 }), entry('b', { createdAt: 4 }), entry('c', { createdAt: 3 })] + const port: RecordsPort = { + fetchPage: async (source, page, pageSize) => { + calls.push([source.kind, page, pageSize]) + return pageOf(rows.slice(page * pageSize, page * pageSize + pageSize), rows.length, page, pageSize) + }, + fetchById: async () => null, + } + const store = new RecordsListStore(port, undefined, 2) + await store.setSource(all) + expect(store.getState().entries.map((e) => e.id)).toEqual(['a', 'b']) + expect(hasMoreRecords(store.getState(), 2)).toBe(true) + + await store.loadMore() + expect(store.getState().entries.map((e) => e.id)).toEqual(['a', 'b', 'c']) + expect(hasMoreRecords(store.getState(), 2)).toBe(false) + expect(calls).toEqual([ + ['all', 0, 2], + ['all', 1, 2], + ]) + + // 새로 고침은 불러온 범위 전체를 한 번에 + await store.reload() + expect(calls[2]).toEqual(['all', 0, 4]) + }) + + it('첫 페이지 실패는 error 상태 — 무시하지 않는다. 다시 시도하면 회복한다', async () => { + let fail = true + const port: RecordsPort = { + fetchPage: async () => (fail ? null : pageOf([entry('a')], 1)), + fetchById: async () => null, + } + const store = new RecordsListStore(port, undefined, 2) + await store.setSource(all) + expect(store.getState().status).toBe('error') + fail = false + await store.reload() + expect(store.getState().status).toBe('ready') + expect(store.getState().entries).toHaveLength(1) + }) + + it('다음 페이지 실패는 목록을 두고 loadMoreFailed 로 알린다', async () => { + const port: RecordsPort = { + fetchPage: async (_s, page) => (page === 0 ? pageOf([entry('a'), entry('b')], 5) : Promise.reject(new Error('x'))), + fetchById: async () => null, + } + const store = new RecordsListStore(port, undefined, 2) + await store.setSource(all) + await store.loadMore() + expect(store.getState().entries).toHaveLength(2) + expect(store.getState().loadMoreFailed).toBe(true) + }) + + it('검색어가 바뀌면 늦게 도착한 이전 응답을 버린다', async () => { + const slow = deferred() + const port: RecordsPort = { + fetchPage: (source) => + source.kind === 'search' && source.query === 'old' ? slow.promise : Promise.resolve(pageOf([entry('new')], 1)), + fetchById: async () => null, + } + const store = new RecordsListStore(port, undefined, 2) + const first = store.setSource({ kind: 'search', query: 'old' }) + await store.setSource({ kind: 'search', query: 'new' }) + slow.resolve(pageOf([entry('stale')], 1)) + await first + expect(store.getState().entries.map((e) => e.id)).toEqual(['new']) + }) + + it('홈에서 넘긴 오래된 기록은 한 건 가져와 끼워 넣고, 새로 고쳐도 남긴다', async () => { + const port: RecordsPort = { + fetchPage: async () => pageOf([entry('recent', { createdAt: 10 })], 100), + fetchById: async (id) => (id === 'old' ? entry('old', { createdAt: 1 }) : null), + } + const store = new RecordsListStore(port, undefined, 2) + await store.setSource(all) + expect(await store.ensureRecord('old')).toBe(true) + expect(await store.ensureRecord('missing')).toBe(false) + await store.reload() + expect(store.getState().entries.map((e) => e.id)).toEqual(['recent', 'old']) + }) + + it('삭제가 확정되면 목록과 건수에서 뺀다', async () => { + const port: RecordsPort = { + fetchPage: async () => pageOf([entry('a'), entry('b')], 2), + fetchById: async () => null, + } + const store = new RecordsListStore(port, undefined, 2) + await store.setSource(all) + store.remove('a') + expect(store.getState().entries.map((e) => e.id)).toEqual(['b']) + expect(store.getState().total).toBe(1) + }) +}) + +describe('HistoryPage 배선 — 기록 화면', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../src/renderer/pages/HistoryPage.tsx'), 'utf-8') + + it('제목은 메뉴와 같은 "기록"', () => { + expect(src).toMatch(/title=\{t\('nav\.records'\)\}/) + }) + + it('홈에서 넘긴 recordId·query 를 받는다', () => { + expect(src).toMatch(/useRouteRequest\('records'/) + }) + + it('한 건 삭제는 확인 없이 숨기고 알림이 끝날 때(onExpire) 지운다', () => { + expect(src).toMatch(/onExpire:\s*\(\)\s*=>\s*void commitDelete\(id\)/) + }) +}) diff --git a/apps/desktop/tests/unit/renderer-tools-model.test.ts b/apps/desktop/tests/unit/renderer-tools-model.test.ts new file mode 100644 index 0000000..43858bf --- /dev/null +++ b/apps/desktop/tests/unit/renderer-tools-model.test.ts @@ -0,0 +1,97 @@ +// tests/unit/renderer-tools-model.test.ts +// 도구 화면(명령어·AI 대화·지식 베이스·사전)의 순수 판단: +// - 명령어 요약·라디오 선택은 main 의 경로 규칙과 같다(지시문은 기본 액션 'custom' 일 때만) +// - 시험 실행은 실제 경로를 따른다(원문 삽입 설정에서 'refine' 을 돌리지 않는다) +// - AI 대화 상태 표지는 세션이 꺼져 있으면 없다 +// - 지연 삭제 중인 항목은 목록에서 빠진다 + +import { describe, expect, it } from 'vitest' +import { + COMMAND_NONE, + commandSelection, + commandTestRequest, + conversationBadge, + documentIndexLabelKey, + llmActionLabelKey, + resolveCommandPipeline, + withoutHidden, +} from '../../src/renderer/pages/tools/tools-model' + +const polish = { id: 'i1', name: '정중하게', prompt: '{{text}} 를 정중하게' } +const list = [polish, { id: 'i2', name: '요약', prompt: '{{text}} 요약' }] + +describe('resolveCommandPipeline', () => { + it("기본 액션 'none' 이면 원문 그대로 — 활성 지시문 id 가 남아 있어도", () => { + const p = resolveCommandPipeline({ defaultLLMAction: 'none', activeInstructionId: 'i1' }, list) + expect(p.kind).toBe('raw') + expect(commandSelection(p)).toBe(COMMAND_NONE) + }) + + it("'custom' + 활성 지시문이면 그 지시문", () => { + const p = resolveCommandPipeline({ defaultLLMAction: 'custom', activeInstructionId: 'i1' }, list) + expect(p).toEqual({ kind: 'instruction', instruction: polish }) + expect(commandSelection(p)).toBe('i1') + }) + + it("'custom' 인데 지시문이 지워졌으면 '선택 안 됨'으로 정직하게", () => { + const p = resolveCommandPipeline({ defaultLLMAction: 'custom', activeInstructionId: 'gone' }, list) + expect(p.kind).toBe('instruction-missing') + expect(commandSelection(p)).toBe('') + }) + + it('일반 액션이 기본이면 활성 지시문이 있어도 그 액션이다(main 규칙과 같다) — 라디오는 아무것도 고르지 않는다', () => { + const p = resolveCommandPipeline({ defaultLLMAction: 'refine', activeInstructionId: 'i1' }, list) + expect(p).toEqual({ kind: 'action', action: 'refine' }) + expect(commandSelection(p)).toBe('') + expect(llmActionLabelKey('refine')).toBe('settings.action.refine') + }) +}) + +describe('commandTestRequest', () => { + it('지시문 경로는 custom + 지시문 원문으로 요청한다(치환은 main 이 한다)', () => { + const r = commandTestRequest({ kind: 'instruction', instruction: polish }, ' 안녕 ') + expect(r).toEqual({ ok: true, params: { text: '안녕', action: 'custom', customPrompt: polish.prompt } }) + }) + + it('원문 삽입 설정에서는 실행하지 않는다 — 예전엔 refine 결과를 보여 줬다', () => { + expect(commandTestRequest({ kind: 'raw' }, '안녕')).toEqual({ ok: false, reason: 'raw' }) + }) + + it('체인은 한 번의 LLM 호출로 흉내 내지 않는다', () => { + expect(commandTestRequest({ kind: 'action', action: 'chain' }, '안녕')).toEqual({ ok: false, reason: 'chain' }) + }) + + it('빈 입력은 실행하지 않는다', () => { + expect(commandTestRequest({ kind: 'action', action: 'refine' }, ' ')).toEqual({ ok: false, reason: 'empty' }) + }) +}) + +describe('conversationBadge', () => { + it('세션이 꺼져 있으면 표지가 없다 — 켜지지 않은 대화를 대기라 쓰지 않는다', () => { + expect(conversationBadge({ active: false, state: 'idle', connecting: false })).toBeNull() + }) + + it('강조색은 듣는 중에만', () => { + expect(conversationBadge({ active: true, state: 'listening', connecting: false })?.tone).toBe('accent') + expect(conversationBadge({ active: true, state: 'thinking', connecting: false })?.tone).toBe('default') + }) + + it('연결 중이면 세션 상태보다 먼저 연결 중이라 쓴다', () => { + expect(conversationBadge({ active: true, state: 'idle', connecting: true })?.labelKey).toBe( + 'conversation.realtime.connecting', + ) + }) +}) + +describe('withoutHidden / documentIndexLabelKey', () => { + it('지연 삭제 중인 id 를 목록에서 뺀다', () => { + expect(withoutHidden(list, new Set(['i1'])).map((i) => i.id)).toEqual(['i2']) + expect(withoutHidden(list, new Set())).toHaveLength(2) + }) + + it('색인이 끝나지 않은 문서는 진행 중일 때만 색인 중, 아니면 색인 안 됨', () => { + expect(documentIndexLabelKey({ id: 'd', indexed: false }, 'd')).toBe('rag.indexing') + expect(documentIndexLabelKey({ id: 'd', indexed: false }, null)).toBe('tools.knowledge.notIndexed') + expect(documentIndexLabelKey({ id: 'd', indexed: true }, null)).toBe('rag.indexed') + }) +}) diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 6d6f240..cad2111 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -137,8 +137,8 @@ "settings.sttProvider.assemblyai": "AssemblyAI Universal-2 (Acoustic AI)", "settings.sttProvider.google": "Google Gemini 2.0 Flash Audio", "settings.sttProvider.custom": "Custom (OpenAI-Compatible)", - "settings.sttFallbackToLocal": "Auto Fallback to Local Whisper", - "settings.sttFallbackToLocalHint": "Automatically switch to offline local Whisper if cloud STT fails", + "settings.sttFallbackToLocal": "Fall back to local Whisper", + "settings.sttFallbackToLocalHint": "If cloud recognition fails (offline, quota exceeded, invalid key), the audio is recognized again with Whisper on this PC.", "settings.whisperModel": "Dictation model", "settings.model.tiny": "tiny (39 MB, fastest)", "settings.model.base": "base (74 MB, balanced)", @@ -373,9 +373,9 @@ "conversation.inputPlaceholder": "Type a message...", "conversation.end": "End", "conversation.clearHistory": "Clear history", - "conversation.recording.hint": "SPEAK NOW · PRESS ⏹ TO SEND", + "conversation.recording.hint": "Speak now · press ⏹ to send", "conversation.thinking.placeholder": "Waiting for response…", - "conversation.state.recording": "RECORDING", + "conversation.state.recording": "Recording", "conversation.error.phase.stt": "Speech Recognition", "conversation.error.phase.llm": "Response", "conversation.error.phase.tts": "Voice Playback", @@ -1686,7 +1686,7 @@ "keybinding.disabled.mouseLeft": "Left click is used for every on-screen interaction, so it cannot be bound", "keybinding.warning.mousePassthrough": "Mouse buttons also keep performing their original action", "keybinding.ui.add": "Add", - "keybinding.ui.remove": "Remove", + "keybinding.ui.remove": "Remove {{keys}}", "keybinding.ui.reset": "Reset to Default", "keybinding.ui.tabRecord": "Press a Key", "keybinding.ui.tabList": "Choose from List", @@ -1781,13 +1781,13 @@ "input.suggestion.onScreen": "On screen", "input.suggestion.offScreen": "Hidden", "input.suggestion.generating": "Generating…", - "input.kbView.knowledge": "Knowledge base", + "input.kbView.knowledge": "Knowledge", "input.kbView.insights": "Input insights", "input.insights.tabs.overview": "Overview", "input.insights.tabs.keyboard": "Keyboard", "input.insights.tabs.mouse": "Mouse", "input.insights.tabs.apps": "Apps", - "input.insights.tabs.phrases": "Phrases · suggestions", + "input.insights.tabs.phrases": "Phrases & suggestions", "input.insights.chars": "Characters typed", "input.insights.wordChars": "Characters", "input.insights.backspaces": "Backspaces", @@ -1835,7 +1835,7 @@ "popup.suggestion.hintGeneratingMore": "More coming… (up to {{max}})", "keybinding.action.suggestionPrev": "Previous suggestion", "keybinding.action.suggestionPrev.desc": "Move to the previous suggestion candidate.", - "input.insights.tabs.graph": "Graph", + "input.insights.tabs.graph": "Sentence graph", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Sentence nodes", "input.graph.followsEdges": "Follows edges", @@ -1932,5 +1932,620 @@ "history.stopAudio": "Stop playback", "history.audioUnavailable": "No recording to play (recordings from another device play once that device has synced)", "cloudSync.audio": "Sync recordings too", - "cloudSync.audioDesc": "Uploads recordings so the phone and web can play them. Files over 50 MB stay on this computer." + "cloudSync.audioDesc": "Uploads recordings so the phone and web can play them. Files over 50 MB stay on this computer.", + "nav.home": "Home", + "nav.records": "Records", + "nav.tools": "Tools", + "nav.aiChat": "AI chat", + "nav.support": "Support", + "nav.mainLabel": "Main navigation", + "nav.shortcutHint": "Ctrl+{{n}}", + "shell.localAi.connected": "Local AI connected", + "shell.localAi.offline": "Local AI off", + "shell.localAi.checking": "Checking local AI", + "shell.localAi.setup": "How to turn on", + "shell.live.caption": "Captions on", + "shell.live.meeting": "Recording meeting", + "shell.rewardClaimed": "{{tokens}} cloud polish credits added.", + "home.title": "Today", + "home.dictationHint": "Hold and speak to dictate; release to insert at the cursor.", + "home.dictationUnbound": "No dictation shortcut is set.", + "home.setShortcut": "Set shortcut", + "home.action.newMeeting": "New meeting", + "home.action.captionStart": "Turn on live captions", + "home.action.captionStop": "Turn off captions", + "home.action.captionStarting": "Starting captions", + "home.action.captionBlocked": "While a meeting is recording, the meeting owns captions.", + "home.recentRecords": "Recent records", + "home.recentMeetings": "Recent meetings", + "home.viewAll": "View all", + "home.emptyRecords": "No records yet.", + "home.emptyMeetings": "No meetings yet.", + "home.summary.sessions": "{{count}} today", + "home.summary.words": "{{count}} words", + "home.summary.minutes": "{{time}} recorded", + "home.summary.streak": "{{count}}-day streak", + "home.summary.label": "Today's usage", + "home.fileTranscription": "Transcribe a file", + "home.status.llmOffline": "Local AI (Ollama) is off, so polishing, summaries and minutes are unavailable. Dictation still works.", + "home.status.openGuide": "How to turn on", + "home.usage.title": "Today's usage", + "home.usage.unlimited": "Unlimited", + "home.meetingDuration": "{{time}}", + "home.meetingInProgress": "In progress", + "record.mode.dictation": "Dictation", + "record.mode.caption": "Captions", + "record.mode.file": "File", + "record.mode.command": "Command", + "record.mode.translate": "Translation", + "record.mode.conversation": "Conversation", + "license.feature.premium_llm": "Cloud AI polish", + "license.feature.cloud_sync": "Sync across devices", + "license.feature.team_workspace": "Team workspace", + "license.team": "Team", + "license.enterprise": "Enterprise", + "common.clear": "Clear", + "common.undo": "Undo", + "common.deleted": "Deleted.", + "common.copied": "Copied.", + "voice.error.micOpen": "Couldn't open the microphone. Check the input device in Settings → Audio.", + "voice.error.micLost": "The microphone disconnected while recording.", + "voice.error.noSpeech": "No speech was heard. Check your microphone input.", + "voice.error.tooShort": "Too short to transcribe.", + "voice.error.transcription": "Transcription failed. Please try again.", + "voice.error.captionActive": "Live captions are on, so dictation can't start.", + "voice.error.llmFailed": "AI polishing failed, so nothing was inserted.", + "popup.tip.processing": "Transcribing", + "popup.tip.waitingEngine": "Preparing speech engine", + "popup.tip.inserted": "Inserted", + "popup.tip.cancelHint": "Esc to cancel", + "popup.tip.recordingLabel": "Recording", + "popup.close": "Close", + "records.searchPlaceholder": "Search records — text, title, summary", + "records.filter.label": "Record filters", + "records.filter.all": "All", + "records.filter.commandTranslate": "Command · Translation", + "records.filter.favorites": "Favorites only", + "records.filter.clear": "Clear filters", + "records.tags.label": "Tags", + "records.tags.showAll": "Show {{count}} more tags", + "records.tags.showLess": "Show fewer tags", + "records.empty.title": "No records yet", + "records.empty.description": "Press your dictation shortcut and speak — what you dictate is saved here by date.", + "records.empty.shortcut": "Dictation shortcut", + "records.empty.unbound": "No dictation shortcut is set yet.", + "records.empty.setShortcut": "Set shortcut", + "records.noResults.title": "No results", + "records.noResults.description": "Try a different search or filter.", + "records.noResults.partial": "Nothing in the {{count}} records loaded so far. You can keep searching older records.", + "records.searchOlder": "Search older records", + "records.loadMore": "Load more", + "records.loadingMore": "Loading", + "records.loading": "Loading records", + "records.shown": "{{shown}} of {{total}} loaded", + "records.loadError.title": "Couldn't load records", + "records.loadError.description": "Something went wrong reading the record store. Try again, and restart the app if it keeps happening.", + "records.loadMoreError": "Couldn't load more records.", + "records.status.cancelled": "Cancelled", + "records.status.error": "Error", + "records.section.polished": "Polished", + "records.section.original": "Original", + "records.showOriginal": "Show original", + "records.hideOriginal": "Hide original", + "records.action.copyOriginal": "Copy original", + "records.action.copyFailed": "Couldn't copy. Clipboard access may be blocked.", + "records.action.removeTag": "Remove tag #{{tag}}", + "records.deleteFailed": "Couldn't delete the record, so it's back in the list. Try again.", + "records.favoriteFailed": "Couldn't change the favorite. Try again.", + "records.more": "More", + "records.clearAllAction": "Delete all records", + "records.clearAllTitle": "Delete all records?", + "records.clearAllBody": "All {{count}} records will be removed. If device sync is on, they're removed from your other devices too. This can't be undone.", + "records.clearAllIgnoresFilter": "This deletes every record, regardless of the current search or filters.", + "records.clearAllConfirm": "Delete all", + "records.clearAllDone": "Deleted all records.", + "records.clearAllFailed": "Couldn't delete all records. Try again.", + "records.exportHint": "Saves tagged records to a Markdown file, grouped by tag. If a tag is selected, only that tag is saved.", + "records.exportDone": "Saved as Markdown: {{path}}", + "records.exportFailed": "Couldn't export. Try again.", + "license.section.plan": "Plan", + "license.trial.title": "Pro+ trial", + "license.trial.remaining": "{{days}} days left. When the trial ends, your plan changes to Free.", + "license.action.subscribePro": "Subscribe to Pro", + "license.action.subscribeProPlus": "Subscribe to Pro+", + "license.action.upgradeProPlus": "Upgrade to Pro+", + "license.plan.active": "You're on the {{plan}}.", + "license.teamPlan": "Team plan", + "license.enterprisePlan": "Enterprise plan", + "license.freePlan": "Free plan", + "license.key.title": "License key", + "license.key.desc": "Paste the offline license key you received (D3RO-LIC-…) and activate it.", + "license.key.activate": "Activate", + "license.key.deactivate": "Deactivate", + "license.key.activated": "License key activated.", + "license.key.invalid": "Couldn't verify this license key. Make sure you pasted the whole key exactly as issued.", + "license.key.failed": "Couldn't activate: {{error}}", + "license.key.deactivated": "License deactivated. Your plan is now Free.", + "license.quotaPerDay": "{{limit}} per day", + "license.quotaPerWeek": "{{limit}} per week", + "cloudSync.account": "Account", + "cloudSync.signInDesc": "Sign in with Google or GitHub to start syncing.", + "cloudSync.status": "Sync status", + "support.title": "Support", + "support.tab.faq": "FAQ", + "support.tab.contact": "Contact us", + "support.faq.search": "Search questions", + "support.faq.empty": "No matching questions.", + "support.faq.mic.q": "Speech recognition doesn't work or no sound is picked up.", + "support.faq.mic.a": "1. In Windows Settings > Privacy & security > Microphone, make sure \"Let desktop apps access your microphone\" is on.\n2. In D3RO Voice Settings > Audio, make sure the microphone you actually use is selected.\n3. If it still doesn't work, email us from the \"Contact us\" tab.", + "support.faq.gpu.q": "How do I turn on NVIDIA CUDA GPU acceleration?", + "support.faq.gpu.a": "If an NVIDIA graphics card and the CUDA runtime are available, the faster-whisper (CTranslate2) engine detects the GPU and runs in GPU mode. Larger models need more VRAM.", + "support.faq.refund.q": "How do I request a refund?", + "support.faq.refund.a": "Refunds and cancellations follow the terms shown at purchase and the policy of the payment processor (Google Play, etc.). Refunds are not issued automatically in the app. Email us from the \"Contact us\" tab with the \"Billing / refund\" category and we'll get back to you.", + "support.faq.invoice.q": "How do I request a business tax invoice?", + "support.faq.invoice.a": "Email us from the \"Contact us\" tab with the \"Billing / refund\" category, including your business details and payment history.", + "support.category.audio": "Microphone / audio", + "support.category.gpu": "CUDA GPU / model loading errors", + "support.category.billing": "Billing / refund", + "support.category.general": "Other", + "support.contact.intro": "We take questions by email. Fill in the form and press the button to open a draft in your mail app. The question reaches us only after you send it from your mail app.", + "support.contact.category": "Category", + "support.contact.subject": "Subject", + "support.contact.subjectPlaceholder": "Summarize the problem in one line", + "support.contact.details": "Details", + "support.contact.detailsPlaceholder": "Describe when the problem happened", + "support.contact.required": "This field is required.", + "support.contact.diagnostics": "Diagnostic info included in the email", + "support.contact.submit": "Write in mail app", + "support.contact.opened": "Opened a draft in your mail app. Press send there to deliver your question.", + "support.contact.openFailed": "Couldn't open your mail app. Email us directly at the address below.", + "support.contact.address": "Support email", + "ads.label": "Ad", + "ads.testAd": "Test ad", + "ads.learnMore": "Learn more", + "ads.removeWithPro": "Remove ads with Pro", + "ads.rewarded.title": "Watch an ad to recharge", + "ads.rewarded.unavailableTitle": "No ad available", + "ads.rewarded.checking": "Looking for a verified ad…", + "ads.rewarded.notAvailable": "No verified ad is available right now. Try again in a moment.", + "ads.rewarded.unreachable": "Couldn't reach the ad provider. Check your internet connection and try again.", + "ads.rewarded.verifyFailed": "Couldn't verify the view, so nothing was added. Try again.", + "ads.rewarded.verifyNote": "Credits are added only after the server verifies the view.", + "ads.rewarded.ready": "Finished", + "ads.rewarded.remaining": "{{seconds}}s left", + "ads.rewarded.claim": "Recharge", + "ads.rewarded.progress": "Ad playback progress", + "onboarding.wizard.title": "Get started with D3RO Voice", + "onboarding.wizard.progress": "Setup progress", + "onboarding.wizard.skip": "Set up later", + "onboarding.wizard.skipHint": "Skipping starts with the defaults (local mode). You can reopen this guide from Settings › About.", + "onboarding.mode.intro": "Choose where speech recognition and AI processing run. You can change this in Settings at any time.", + "onboarding.mode.local.title": "Local mode", + "onboarding.mode.local.badge": "Recommended", + "onboarding.mode.local.desc": "Uses Ollama and local Whisper to recognize speech and refine text on this PC.", + "onboarding.mode.local.point1": "Works without an internet connection", + "onboarding.mode.local.point2": "Speech recognition and AI processing stay on this PC", + "onboarding.mode.local.point3": "No API fees (requires installing Ollama and downloading a model)", + "onboarding.mode.local.action": "Start in local mode", + "onboarding.mode.online.title": "Online mode", + "onboarding.mode.online.desc": "Sign in with a D3RO account to use server-side speech recognition and AI models.", + "onboarding.mode.online.point1": "No local models to install", + "onboarding.mode.online.point2": "Sync across devices", + "onboarding.mode.online.point3": "Requires a D3RO account", + "onboarding.mode.online.action": "Sign in with an account", + "onboarding.local.connected": "Connected to Ollama", + "onboarding.local.connectedVersion": "Connected to Ollama (v{{version}})", + "onboarding.local.disconnected": "Not connected to Ollama", + "onboarding.local.connectedDetail": "{{count}} models installed · Using {{model}}", + "onboarding.local.disconnectedDetail": "Ollama is not installed or not running.", + "onboarding.local.recheck": "Check again", + "onboarding.local.start": "Start Ollama", + "onboarding.local.installTitle": "If you haven't installed Ollama yet", + "onboarding.local.installStep1": "1. Download and install Ollama from ollama.com.", + "onboarding.local.installStep2": "2. After installing, select 'Check again' and D3RO Voice connects to Ollama.", + "onboarding.local.openDownload": "Open ollama.com/download", + "onboarding.local.modelTitle": "Download the default AI model ({{model}})", + "onboarding.local.modelDesc": "The default model for refining dictation, summaries, and translation. Available once Ollama is connected.", + "onboarding.local.pull": "Download {{model}}", + "onboarding.local.modelReady": "An AI model is already installed.", + "onboarding.local.finish": "Finish setup", + "onboarding.local.finishHint": "You can finish setup once Ollama is connected.", + "onboarding.auth.intro": "Sign in with your D3RO account to start in online mode.", + "onboarding.auth.email": "Email", + "onboarding.auth.password": "Password", + "onboarding.auth.submitLogin": "Sign in", + "onboarding.auth.submitRegister": "Create account", + "onboarding.auth.processing": "Working…", + "onboarding.auth.failed": "Couldn't sign in. Check your email and password.", + "onboarding.auth.error": "Couldn't sign in: {{message}}", + "onboarding.done.title": "Setup complete", + "onboarding.done.desc": "You can change the STT provider and Ollama model in Settings.", + "onboarding.done.start": "Get started", + "onboarding.failedPhase": "Setup failed. {{message}}", + "ollamaGuide.start.running": "Ollama is already running.", + "ollamaGuide.start.starting": "Started the Ollama server. Checking the connection.", + "ollamaGuide.start.notInstalled": "Ollama isn't installed. Download and install it from ollama.com.", + "ollamaGuide.start.failed": "Couldn't start Ollama. Start Ollama yourself, then select 'Check again'.", + "ollamaGuide.title": "Ollama setup", + "ollamaGuide.status.connected": "Connected to Ollama", + "ollamaGuide.status.connectedVersion": "Connected to Ollama (v{{version}})", + "ollamaGuide.status.disconnected": "Not connected to Ollama", + "ollamaGuide.status.detail": "Server {{url}} · Using {{model}}", + "ollamaGuide.recheck": "Check again", + "ollamaGuide.start": "Start Ollama", + "ollamaGuide.starting": "Starting…", + "ollamaGuide.step1.title": "1. Install and start Ollama", + "ollamaGuide.step1.desc": "Ollama is a free, open-source program that runs LLMs on this PC. Install and start it, and D3RO Voice connects to it.", + "ollamaGuide.step1.download": "Download from ollama.com", + "ollamaGuide.step2.title": "2. Download a model", + "ollamaGuide.step2.desc": "Download a model for refining dictation, translation, and summaries. Ollama must be connected.", + "ollamaGuide.step2.installedCount": "{{count}} models installed", + "ollamaGuide.model.recommended": "Recommended", + "ollamaGuide.model.gemma4e4b": "The app's default model. It doesn't use reasoning (thinking) tokens, so dictation refinement responds quickly.", + "ollamaGuide.model.llama32": "A lightweight model from Meta, used for grammar fixes and tone adjustment.", + "ollamaGuide.model.qwen25": "A lightweight model that supports many languages, including Korean.", + "ollamaGuide.model.phi4": "A larger model for summaries and translation. 16 GB RAM or more recommended.", + "ollamaGuide.model.inUse": "In use", + "ollamaGuide.model.select": "Set as default", + "ollamaGuide.model.pull": "Download", + "ollamaGuide.model.pullAria": "Download {{model}}", + "ollamaGuide.terminalHint": "To download from a terminal, run this command.", + "ollamaGuide.step3.title": "3. Check that it works", + "ollamaGuide.step3.desc": "Enter a sentence and run it — the model in use returns a refined version.", + "ollamaGuide.test.sample": "Hello, tell me today's weather", + "ollamaGuide.test.placeholder": "Sentence to refine", + "ollamaGuide.test.inputLabel": "Test sentence", + "ollamaGuide.test.run": "Run refine", + "ollamaGuide.test.running": "Working", + "ollamaGuide.test.result": "Refined result ({{model}})", + "ollamaGuide.test.noResponse": "The model didn't respond.", + "ollamaGuide.test.error": "Couldn't run: {{message}}", + "ollamaGuide.footer": "With Ollama, AI processing stays on this PC.", + "codexGuide.title": "Connecting OpenAI and Codex", + "codexGuide.tab.apiKey": "OpenAI API key", + "codexGuide.tab.codex": "Codex / ChatGPT subscription", + "codexGuide.tab.providers": "Other STT service keys", + "codexGuide.apiKey.introTitle": "Connect with an OpenAI API key", + "codexGuide.apiKey.introDesc": "The OpenAI Whisper API is billed per minute of use, separately from a ChatGPT subscription. See OpenAI's pricing page for rates.", + "codexGuide.apiKey.step1.title": "1. Open the API Keys page on OpenAI Platform", + "codexGuide.apiKey.step1.desc": "Sign in with your OpenAI account to see your API keys.", + "codexGuide.apiKey.step1.action": "Open API Keys", + "codexGuide.apiKey.step2.title": "2. Create a new secret key and copy it", + "codexGuide.apiKey.step2.desc": "Select 'Create new secret key' and enter a name to create a key starting with sk-proj-.", + "codexGuide.apiKey.step2.warning": "The key is shown only once when created, so copy it right away.", + "codexGuide.apiKey.step3.title": "3. Paste it into STT settings and test the connection", + "codexGuide.apiKey.step3.desc": "In Settings › STT, choose OpenAI, paste the key into the API key field, and select 'Test connection' to confirm the key works.", + "codexGuide.codex.introTitle": "For ChatGPT Plus, Pro, and Team subscribers", + "codexGuide.codex.introDesc": "A ChatGPT subscription and the OpenAI Platform API are separate account systems. The Codex CLI authenticates by getting a token through browser OAuth (Device Flow).", + "codexGuide.codex.step1.title": "1. Run the Device Auth login in a terminal", + "codexGuide.codex.step1.desc": "Enter the 8-character code shown on screen on the browser authentication page.", + "codexGuide.codex.step2.title": "2. If it doesn't connect", + "codexGuide.codex.step2.proxy": "If a VPN or corporate proxy blocks the localhost redirect, use the --device-auth option.", + "codexGuide.codex.step2.expired": "When the OAuth token expires, sign in again or use an API key instead.", + "codexGuide.copyCommand": "Copy command", + "codexGuide.providers.intro": "You can get an API key from these services and register it in STT settings.", + "codexGuide.providers.groq": "Runs Whisper Large V3 on Groq's servers. Has a free usage limit.", + "codexGuide.providers.deepgram": "Supports automatic punctuation and formatting. Free credit on sign-up.", + "codexGuide.providers.google": "Get an API key from Google AI Studio.", + "codexGuide.providers.assemblyai": "Get an API key from the AssemblyAI dashboard.", + "codexGuide.providers.open": "Get {{name}} key", + "records.notFound": "Couldn't find this record. It may have been deleted.", + "records.tags.filterAria": "Filter by tag", + "meetings.header.mic": "Mic: {{name}}", + "meetings.header.engine": "Speech recognition: {{name}}", + "meetings.engine.local": "Local Whisper", + "meetings.defaultMic": "Default microphone", + "meetings.starting": "Preparing…", + "meetings.searchPlaceholder": "Search meeting titles", + "meetings.empty.title": "No meetings yet", + "meetings.empty.description": "Record a meeting to keep its transcript, then create a summary and documents.", + "meetings.noResults.title": "No matching meetings", + "meetings.noResults.description": "Only titles are searched. Try another term.", + "meetings.noResults.partial": "Not found in the {{count}} loaded meetings. Load older meetings to keep looking.", + "meetings.loadMore": "Load more", + "meetings.loadingMore": "Loading", + "meetings.shown": "{{shown}} of {{total}}", + "meetings.loadError.title": "Couldn't load meetings", + "meetings.loadError.description": "Something went wrong reading the meeting store. Try again, and restart the app if it keeps happening.", + "meetings.loadMoreError": "Couldn't load more meetings.", + "meetings.memoCount": "{{count}} notes", + "meetings.status.recording": "Recording", + "meetings.status.processing": "Saving", + "meetings.status.error": "Error", + "meetings.status.completed": "Done", + "meetings.more": "More", + "meetings.rename": "Rename", + "meetings.renameDialog.title": "Rename meeting", + "meetings.renameDialog.label": "Title", + "meetings.renameFailed": "Couldn't rename: {{message}}", + "meetings.delete.title": "Delete “{{title}}”?", + "meetings.delete.body": "Its transcript, meeting notes, documents and audio are deleted too — on every synced device. This can't be undone.", + "meetings.deleted": "Meeting deleted.", + "meetings.deleteFailed": "Couldn't delete the meeting: {{message}}", + "meetings.live.title": "Recording meeting", + "meetings.live.stopping": "Stopping", + "meetings.live.stopFailed": "Couldn't stop recording: {{message}}", + "meetings.live.transcript": "Live transcript", + "meetings.live.editHint": "Click a past sentence to fix it", + "meetings.live.segmentCount": "{{count}} sentences", + "meetings.live.waiting": "The transcript appears here once people start talking.", + "meetings.live.editFailed": "Couldn't save the transcript edit: {{message}}", + "meetings.live.memoTitle": "Meeting notes", + "meetings.live.memoHint": "Notes are saved with their timestamp and weighted first when you create a summary.", + "meetings.live.memoPlaceholder": "Type a note, press Enter", + "meetings.live.memoFailed": "Couldn't save the note: {{message}}", + "meetings.live.level": "Input level", + "meetings.live.elapsed": "Elapsed time", + "meetings.tag.decision": "#decision", + "meetings.tag.todo": "#todo", + "meetings.tag.agenda": "#agenda", + "meetings.tag.idea": "#idea", + "meetings.tag.issue": "#issue", + "meetings.detail.back": "All meetings", + "meetings.detail.copySummary": "Copy summary", + "meetings.detail.copyTranscript": "Copy transcript", + "meetings.detail.summaryCopied": "Summary copied.", + "meetings.detail.transcriptCopied": "Transcript copied.", + "meetings.detail.copyFailed": "Couldn't copy to the clipboard.", + "meetings.export.label": "Export", + "meetings.export.summaryGroup": "Summary", + "meetings.export.transcriptGroup": "Transcript", + "meetings.export.noSummary": "Create a summary to export it", + "meetings.export.done": "Saved: {{path}}", + "meetings.export.failed": "Couldn't export: {{message}}", + "meetings.format.md": "Markdown (.md)", + "meetings.format.txt": "Text (.txt)", + "meetings.format.pdf": "PDF (.pdf)", + "meetings.format.docx": "Word (.docx)", + "meetings.tabs.label": "Meeting content", + "meetings.tab.summary": "Summary", + "meetings.tab.transcript": "Transcript", + "meetings.tab.documents": "Documents", + "meetings.tab.notes": "My notes", + "meetings.ask": "Ask", + "meetings.askClose": "Close questions", + "meetings.askSend": "Send", + "meetings.summary.empty.title": "No summary yet", + "meetings.summary.empty.description": "Turn the transcript into a summary, decisions and to-dos.", + "meetings.summary.empty.noTranscript": "There's no transcript to summarize.", + "meetings.summary.create": "Create summary", + "meetings.summary.creating": "Creating summary…", + "meetings.summary.chooseTemplate": "Use another format", + "meetings.summary.edit": "Edit in Documents", + "meetings.summary.actionItems": "To-dos", + "meetings.summary.copyActionItems": "Copy to-dos", + "meetings.summary.actionItemsCopied": "To-dos copied.", + "meetings.summary.decisions": "Decisions", + "meetings.summary.generateFailed": "Couldn't create the document: {{message}}", + "meetings.doc.new": "New document", + "meetings.doc.untitled": "Document {{n}}", + "meetings.doc.list": "Documents", + "meetings.doc.primaryHint": "The first document is shown in Summary.", + "meetings.doc.empty.title": "No documents", + "meetings.doc.empty.description": "Pick a format — minutes, report, idea note — and build a document from the transcript.", + "meetings.doc.delete.title": "Delete “{{title}}”?", + "meetings.doc.delete.body": "Only this document is deleted, and it can't be undone. The transcript and meeting notes stay.", + "meetings.doc.deleted": "Document deleted.", + "meetings.notes.recordedTitle": "Notes taken while recording", + "meetings.notes.padTitle": "My notes", + "meetings.notes.padHint": "Saved automatically on this device. Not synced to other devices.", + "meetings.notes.placeholder": "Jot down what was discussed, customer feedback, your to-dos.", + "meetings.notes.enhance": "Create minutes from notes", + "meetings.notes.enhancing": "Creating…", + "meetings.notes.enhanceHint": "Combines your notes with the transcript into a new document.", + "meetings.notes.enhancedDocTitle": "Minutes from my notes", + "meetings.notes.saveFailed": "Couldn't save your notes on this device. Check free storage.", + "meetings.transcript.empty": "Nothing was transcribed.", + "meetings.transcript.memo": "Note", + "meetings.error.reason": "Cause", + "meetings.error.fix": "How to fix", + "meetings.error.openAudioSettings": "Open audio settings", + "meetings.error.openSttSettings": "Open speech recognition settings", + "meetings.error.forceStart": "Clean up running session and start", + "meetings.error.forceRetry": "Reset and try again", + "meetings.error.collision.title": "Another meeting or live caption is still running", + "meetings.error.collision.message": "A previous meeting session or caption is still running in the background.", + "meetings.error.collision.reason": "The previous meeting/caption process didn't fully stop, so it collided with the new recording.", + "meetings.error.collision.suggestion": "Clean up the running session to start a new meeting right away.", + "meetings.error.stt.title": "Couldn't prepare speech recognition (STT)", + "meetings.error.stt.message": "The speech recognition engine or Whisper model failed to load.", + "meetings.error.stt.reason": "The local Whisper model isn't ready, or the sidecar process port is in use.", + "meetings.error.stt.suggestion": "Check the model download in speech recognition settings, or reset and try again.", + "meetings.error.audio.title": "Couldn't start the microphone", + "meetings.error.audio.message": "The microphone audio stream couldn't be opened.", + "meetings.error.audio.reason": "No default microphone was found, or Windows microphone access is off.", + "meetings.error.audio.suggestion": "Pick a microphone in audio settings, or retry with the default device.", + "meetings.error.processing.title": "Couldn't save the meeting", + "meetings.error.processing.message": "An error occurred while saving the transcript after recording.", + "meetings.error.processing.reason": "The meeting record couldn't be written to the database after recording stopped.", + "meetings.error.processing.suggestion": "The failed meeting stays in the list with an error status. Check disk space, then open it from the list.", + "meetings.error.general.title": "Couldn't start the meeting", + "meetings.error.general.message": "Meeting recording failed to start for an unknown reason.", + "meetings.error.general.reason": "Something went wrong in background communication or initialization.", + "meetings.error.general.suggestion": "Reset and try starting again.", + "popup.result.title": "Couldn't insert · copy and paste it", + "popup.copied": "Copied", + "popup.hints.move": "Move", + "popup.hints.paste2": "Paste", + "popup.hints.apply2": "Apply", + "popup.hints.close2": "Close", + "popup.hints.pick": "Pick by number", + "popup.history.title": "Recent dictation", + "popup.history.empty": "Nothing dictated yet.", + "popup.command.title": "Choose a command", + "popup.command.empty": "No commands yet.", + "popup.command.none": "Insert original text (no command)", + "settings.closeToTray.desc": "Closing the window keeps D3RO Voice running in the tray so shortcuts keep working.", + "settings.autoLaunch.desc": "Starts D3RO Voice when you sign in to your computer.", + "settings.autoInsert.desc": "Inserts the transcript into the app at your cursor as soon as it's ready.", + "settings.soundEffects.desc": "Plays a sound when recording starts and stops.", + "settings.micTest": "Microphone test", + "settings.micTest.desc": "Press Test and speak — the bar should move. If it doesn't, choose another input device.", + "settings.micTest.level": "Input level", + "settings.micTest.failed": "Couldn't open the microphone. Check that no other app is using it and that microphone access is on in Windows settings.", + "settings.llm.engineSection": "AI engine", + "settings.llm.engine": "Where AI runs", + "settings.llm.engine.desc": "Choose where refinement, summaries, and commands are processed.", + "settings.llm.engine.local": "Local Ollama — runs on this PC, works offline", + "settings.llm.engine.online": "D3RO online API — sign-in required", + "settings.llm.ollamaStatus": "Ollama connection", + "settings.llm.ollamaChecking": "Checking connection", + "settings.llm.ollamaConnected": "Connected · {{count}} models installed", + "settings.llm.ollamaConnectedVersion": "Connected · v{{version}} · {{count}} models installed", + "settings.llm.ollamaOffline": "Not connected — start the Ollama server to use local AI processing.", + "settings.llm.checkConnection": "Check connection", + "settings.llm.startOllama": "Start Ollama", + "settings.llm.startingOllama": "Starting", + "settings.llm.guide": "Setup and models", + "settings.llm.guide.desc": "How to install Ollama and which models you can download.", + "settings.llm.openGuide": "Open guide", + "settings.llm.feedback.running": "Ollama is already running.", + "settings.llm.feedback.started": "Started the Ollama server. Checking the connection again shortly.", + "settings.llm.feedback.notInstalled": "Ollama isn't installed. Open the setup guide to install it.", + "settings.llm.feedback.failed": "Couldn't start Ollama. Check the setup guide.", + "settings.llm.serverUrl.desc": "Change only if Ollama runs at a different address.", + "settings.llm.activeModel": "Model", + "settings.llm.activeModel.desc": "The Ollama model used for refinement, summaries, and chat.", + "settings.llm.modelRecommended": "{{name}} (recommended · not installed)", + "settings.llm.modelNotInstalled": "{{name}} (not installed)", + "settings.llm.onlineUrl": "API server URL", + "settings.llm.account": "Account", + "settings.llm.accountSignedIn": "Signed in as {{email}}", + "settings.llm.accountSignedInNoEmail": "Signed in", + "settings.llm.accountSignedOut": "Not signed in — sign in from the License tab to use the online API.", + "settings.about.appName": "D3RO Voice", + "settings.about.summary": "A voice assistant that runs speech recognition and AI processing on this PC. The online API and device sync use the internet only when you turn them on.", + "settings.about.helpSection": "Help", + "settings.about.restartOnboarding.desc": "Opens the first-run guide again. The app window reloads.", + "settings.about.restartOnboarding.action": "Open guide", + "settings.stt.engineSection": "Speech recognition engine", + "settings.stt.openGuide": "OpenAI & Codex setup guide", + "settings.stt.engine": "Engine", + "settings.stt.provider.local.desc": "Recognizes speech with Whisper on this PC. Works offline and never sends audio out.", + "settings.stt.provider.d3roCloud.desc": "Recognizes speech on D3RO servers. Authenticates with your D3RO account, so no API key is needed.", + "settings.stt.provider.openai.desc": "Sends audio to OpenAI for recognition. Requires an OpenAI API key.", + "settings.stt.provider.groq.desc": "Sends audio to Groq for recognition. Fast responses; requires a Groq API key.", + "settings.stt.provider.deepgram.desc": "Sends audio to Deepgram for recognition. Adds punctuation and formatting automatically. Requires an API key.", + "settings.stt.provider.assemblyai.desc": "Sends audio to AssemblyAI for recognition. Requires an API key.", + "settings.stt.provider.google.desc": "Sends audio to Google Gemini for recognition. Supports many languages including Korean. Requires an API key.", + "settings.stt.provider.custom.desc": "Sends audio to an OpenAI-compatible endpoint (vLLM, a self-hosted server, etc.). Set the address under Advanced.", + "settings.stt.modelInstalled": "Installed", + "settings.stt.modelNotDownloaded": "Not downloaded", + "settings.stt.modelNeedsDownload": "{{name}} isn't downloaded yet · about {{size}} MB", + "settings.stt.downloadModel": "Download model", + "settings.stt.downloading": "Downloading {{percent}}%", + "settings.stt.runtime.sidecar": "Local speech engine (faster-whisper)", + "settings.stt.runtime.ffmpeg": "Media converter (ffmpeg)", + "settings.stt.runtime.installed": "Installed · {{size}} MB", + "settings.stt.runtime.missing": "Not installed — required for local transcription", + "settings.stt.runtime.install": "Download", + "settings.stt.runtime.reinstall": "Reinstall", + "settings.stt.apiKey": "API key", + "settings.stt.apiKey.desc": "Paste the key issued by {{name}}. Use Test connection to check it.", + "settings.stt.apiKeyPlaceholder": "Paste API key", + "settings.stt.account": "D3RO account", + "settings.stt.account.desc": "Authenticates with the D3RO account you're signed in to. Use Test connection to check access.", + "settings.stt.showKey": "Show key", + "settings.stt.hideKey": "Hide key", + "settings.stt.testConnection": "Test connection", + "settings.stt.testing": "Testing", + "settings.stt.languageSection": "Language", + "settings.stt.advancedSection": "Advanced", + "settings.stt.advanced.desc": "Model ID · endpoint · fallback", + "settings.stt.advanced.show": "Show", + "settings.stt.advanced.hide": "Hide", + "settings.stt.modelId": "Model ID", + "settings.stt.modelId.desc": "Default: {{model}}", + "settings.stt.modelId.auto": "automatic", + "settings.stt.baseUrl": "API endpoint URL", + "settings.stt.baseUrl.desc": "Change only when using a proxy or self-hosted server.", + "tools.builtin": "Built-in", + "tools.editItem": "Edit {{name}}", + "tools.deleteItem": "Delete {{name}}", + "tools.deleteFailed": "Couldn't delete. The item was restored — try again in a moment.", + "tools.copyFailed": "Couldn't copy. Check clipboard permission.", + "tools.loadMore": "Show more ({{shown}}/{{total}})", + "tools.commands.description": "Choose how AI polishes what you dictate.", + "tools.commands.add": "Add command", + "tools.commands.listTitle": "Command to use", + "tools.commands.listHint": "Your choice applies from the next dictation.", + "tools.commands.none": "No AI command", + "tools.commands.noneHint": "Inserts what you said as-is.", + "tools.commands.inUse": "In use", + "tools.commands.empty": "No commands yet", + "tools.commands.emptyHint": "Select Add command to save a polishing style you use often.", + "tools.commands.loadFailed": "Couldn't load commands.", + "tools.commands.selectFailed": "Couldn't change the command. The previous setting was restored.", + "tools.commands.deleted": "Deleted command '{{name}}'.", + "tools.commands.action.expand": "Expand", + "tools.commands.action.chain": "Chain", + "tools.commands.pipeline.current": "Dictation post-processing:", + "tools.commands.pipeline.missing": "No command selected", + "tools.commands.pipeline.stepsLabel": "Dictation steps", + "tools.commands.pipeline.stepStt": "Speech becomes text, using dictionary words as recognition hints.", + "tools.commands.pipeline.stepRaw": "No AI post-processing.", + "tools.commands.pipeline.stepInstruction": "AI polishes it with '{{name}}'.", + "tools.commands.pipeline.stepMissing": "Set to use a command, but none is selected — pick one below.", + "tools.commands.pipeline.stepAction": "AI applies the default action ({{action}}). Change it in Settings > LLM.", + "tools.commands.pipeline.stepInsert": "The result goes into the window you were typing in.", + "tools.commands.test.title": "Try a command", + "tools.commands.test.description": "Process one sentence the same way dictation does and preview the result.", + "tools.commands.test.placeholder": "Type a sentence as if you had dictated it", + "tools.commands.test.inputLabel": "Sentence to try", + "tools.commands.test.run": "Run", + "tools.commands.test.running": "Running…", + "tools.commands.test.result": "Result", + "tools.commands.test.failed": "Couldn't process: {{reason}}", + "tools.commands.test.blockedRaw": "AI post-processing is off, so the result equals the input.", + "tools.commands.test.blockedChain": "Chains run several steps and can't be tried here.", + "tools.commands.test.blockedMissing": "Pick a command to use first.", + "tools.templates.description": "Say each field in turn and it fills a fixed format.", + "tools.templates.emptyHint": "Select New Template to create a format you use often.", + "tools.templates.fieldCount": "{{count}} fields", + "tools.templates.lastOutput": "Last result", + "tools.templates.deleted": "Deleted template '{{name}}'.", + "tools.templates.startItem": "Start {{name}}", + "tools.templates.removeField": "Remove field {{n}}", + "tools.dictionary.description": "Dictation uses these words as recognition hints (up to 50).", + "tools.dictionary.add": "Add word", + "tools.dictionary.search": "Search words", + "tools.dictionary.empty": "No words yet", + "tools.dictionary.emptyHint": "Add names and jargon that dictation often gets wrong.", + "tools.dictionary.noResultsHint": "Try a different search.", + "tools.dictionary.loadFailed": "Couldn't load the dictionary.", + "tools.dictionary.deleted": "Deleted '{{word}}'.", + "tools.dictionary.pronunciationValue": "said as {{value}}", + "tools.dictionary.pronunciationHelp": "e.g. dee-three-oh — stored for reference; only the word is used as a hint.", + "tools.dictionary.formatJson": "JSON file", + "tools.dictionary.formatCsv": "CSV file", + "tools.knowledge.description": "Ask about the documents you add and get answers from the relevant parts. Documents are indexed on this device.", + "tools.knowledge.ask": "Ask", + "tools.knowledge.examples": "Examples", + "tools.knowledge.example.summary": "Summarize these documents in three lines", + "tools.knowledge.example.schedule": "List the key dates and milestones", + "tools.knowledge.example.policy": "What are the security and privacy policies?", + "tools.knowledge.answer": "Answer", + "tools.knowledge.similarity": "{{percent}}% match", + "tools.knowledge.queryFailed": "Couldn't find an answer. Check document indexing and the AI connection, then ask again.", + "tools.knowledge.queryNeedsDocs": "Add a document first to ask questions", + "tools.knowledge.chunkProgress": "Chunk {{current}}/{{total}} · {{percent}}%", + "tools.knowledge.chunkCount": "{{count}} chunks", + "tools.knowledge.notIndexed": "Not indexed", + "tools.knowledge.reindexItem": "Reindex {{name}}", + "tools.knowledge.deleted": "Deleted document '{{name}}'.", + "tools.knowledge.loadFailed": "Couldn't load documents.", + "tools.knowledge.emptyHint": "Add TXT, MD, PDF or DOCX files to ask about their contents.", + "tools.insights.loadFailed": "Couldn't load input collection status.", + "tools.insights.openSettings": "Open input settings", + "tools.conversation.description": "Talk or type to chat with AI.", + "tools.conversation.modeRealtime": "Realtime mode", + "tools.conversation.start": "Start conversation", + "tools.conversation.finish": "Stop talking and send", + "tools.conversation.stop": "Stop conversation", + "tools.conversation.cancelResponse": "Cancel response", + "tools.conversation.send": "Send", + "tools.conversation.clearTitle": "Clear conversation history?", + "tools.conversation.clearBody": "All {{count}} messages will be removed. This can't be undone.", + "tools.conversation.clearConfirm": "Clear", + "tools.conversation.clearFailed": "Couldn't clear the history. Try again." } diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index e2294a6..692dfa9 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -145,8 +145,8 @@ "settings.sttProvider.assemblyai": "AssemblyAI Universal-2 (음향 모델)", "settings.sttProvider.google": "Google Gemini 2.0 Flash Audio", "settings.sttProvider.custom": "커스텀 OpenAI 호환 엔드포인트", - "settings.sttFallbackToLocal": "로컬 Whisper 자동 폴백", - "settings.sttFallbackToLocalHint": "클라우드 STT 실패 시 오프라인 로컬 Whisper로 자동 전환", + "settings.sttFallbackToLocal": "실패하면 로컬 Whisper로 전환", + "settings.sttFallbackToLocalHint": "클라우드 인식이 실패하면(인터넷 끊김·사용량 초과·키 오류) 이 PC의 Whisper로 다시 인식합니다.", "settings.whisperModel": "받아쓰기 모델", "settings.model.tiny": "tiny (39 MB, 가장 빠름)", "settings.model.base": "base (74 MB, 균형)", @@ -1693,7 +1693,7 @@ "keybinding.disabled.mouseLeft": "왼쪽 클릭은 모든 화면 조작에 쓰이므로 바인딩할 수 없습니다", "keybinding.warning.mousePassthrough": "마우스 버튼은 원래 동작도 함께 실행됩니다", "keybinding.ui.add": "추가", - "keybinding.ui.remove": "삭제", + "keybinding.ui.remove": "{{keys}} 삭제", "keybinding.ui.reset": "기본값으로", "keybinding.ui.tabRecord": "직접 입력", "keybinding.ui.tabList": "목록에서 선택", @@ -1717,7 +1717,7 @@ "keybinding.ui.globalEnabled": "전역 단축키 사용", "input.tab": "입력", "input.consent.title": "입력 데이터 수집", - "input.consent.description": "선택 사항입니다. 키 입력과 클릭 횟수, 마우스 이동 거리를 세어 자신의 입력 패턴을 보여니다. 어떤 키를 눌렀는지는 저장하지 않습니다.", + "input.consent.description": "선택 사항입니다. 키 입력과 클릭 횟수, 마우스 이동 거리를 세어 자신의 입력 패턴을 보여 줍니다. 어떤 키를 눌렀는지는 저장하지 않습니다.", "input.consent.collect": "키보드·마우스 통계 수집", "input.consent.running": "수집 중입니다.", "input.consent.stopped": "수집이 꺼져 있습니다. 아무것도 기록하지 않습니다.", @@ -1729,7 +1729,7 @@ "input.consent.excludedAppsHint": "쉼표로 구분한 실행 파일명입니다. 이 앱에서는 아무것도 읽거나 세지 않습니다.", "input.consent.clearAll": "수집된 데이터 삭제", "input.suggestion.title": "다음 문장 제안", - "input.suggestion.description": "앞으로 나올 문장을 예측해 커서 에 보여니다. 수락하면 그대로 삽입됩니다.", + "input.suggestion.description": "앞으로 나올 문장을 예측해 커서 옆에 보여 줍니다. 수락하면 그대로 삽입됩니다.", "input.suggestion.enabled": "타이핑 중 제안 표시", "input.suggestion.modelReady": "로컬 모델 준비됨.", "input.suggestion.modelMissing": "로컬 모델이 실행 중이 아닙니다 — 제안이 꺼진 상태로 동작합니다.", @@ -1738,10 +1738,10 @@ "input.suggestion.modelDefault": "기본 모델 사용", "input.suggestion.overlayInteractive": "제안 상자 클릭 허용", "input.suggestion.overlayVisible": "제안이 화면에 표시됨", - "input.suggestion.keyHint": "수락·다음 후보·닫기는 전역 키바인딩입니다 — 설정 > 단축키에서 바 수 있습니다.", + "input.suggestion.keyHint": "수락·다음 후보·닫기는 전역 키바인딩입니다 — 설정 > 단축키에서 바꿀 수 있습니다.", "input.suggestion.requestNow": "지금 제안 받기", "input.suggestion.usage": "오늘 {{requests}}/{{budget}}회 사용", - "input.suggestion.lastSkip": "마지막 건너 사유: {{reason}}", + "input.suggestion.lastSkip": "마지막으로 건너뛴 이유: {{reason}}", "input.insights.title": "최근 {{days}}일", "input.insights.keystrokes": "키 입력", "input.insights.clicks": "클릭", @@ -1774,7 +1774,7 @@ "keybinding.action.suggestionDismiss.desc": "현재 제안을 숨깁니다.", "input.suggestion.timeout": "응답 제한 (ms)", "input.suggestion.latency": "마지막 응답: {{ms}}ms", - "input.suggestion.slowWarning": "타이 속도에 비해 느립니다 — 더 작은 모델을 쓰거나 후보 수를 줄여보세요.", + "input.suggestion.slowWarning": "타이핑 속도에 비해 느립니다 — 더 작은 모델을 쓰거나 후보 수를 줄여 보세요.", "input.diagnostics.title": "실시간 진단", "input.diagnostics.app": "포커스 앱: {{app}}", "input.diagnostics.unknownApp": "(알 수 없음)", @@ -1788,13 +1788,13 @@ "input.suggestion.onScreen": "표시 중", "input.suggestion.offScreen": "없음", "input.suggestion.generating": "생성 중…", - "input.kbView.knowledge": "지식 베이스", + "input.kbView.knowledge": "지식", "input.kbView.insights": "입력 인사이트", "input.insights.tabs.overview": "요약", "input.insights.tabs.keyboard": "키보드", "input.insights.tabs.mouse": "마우스", "input.insights.tabs.apps": "앱", - "input.insights.tabs.phrases": "문구 · 제안", + "input.insights.tabs.phrases": "문구와 제안", "input.insights.chars": "입력한 글자", "input.insights.wordChars": "글자", "input.insights.backspaces": "지우기", @@ -1842,7 +1842,7 @@ "popup.suggestion.hintGeneratingMore": "더 생성 중… (최대 {{max}}개)", "keybinding.action.suggestionPrev": "이전 제안", "keybinding.action.suggestionPrev.desc": "이전 제안 후보로 이동합니다.", - "input.insights.tabs.graph": "그래프", + "input.insights.tabs.graph": "문장 그래프", "input.graph.description": "내 문장을 노드로, 문장 사이의 관계(무엇이 무엇 뒤에 오는지, 어떤 용어를 공유하는지)를 엣지로 저장해 제안에 개인 문맥을 끌어옵니다. 전부 로컬입니다.", "input.graph.nodes": "문장 노드", "input.graph.followsEdges": "이어쓰기 연결", @@ -1939,5 +1939,620 @@ "history.stopAudio": "재생 멈춤", "history.audioUnavailable": "들을 수 있는 녹음이 없습니다 (다른 기기 녹음은 그 기기의 동기화가 끝나면 들을 수 있어요)", "cloudSync.audio": "녹음 파일도 동기화", - "cloudSync.audioDesc": "녹음을 클라우드에 올려 모바일·웹에서 들을 수 있게 합니다. 50MB를 넘는 파일은 이 컴퓨터에만 둡니다." + "cloudSync.audioDesc": "녹음을 클라우드에 올려 모바일·웹에서 들을 수 있게 합니다. 50MB를 넘는 파일은 이 컴퓨터에만 둡니다.", + "nav.home": "홈", + "nav.records": "기록", + "nav.tools": "도구", + "nav.aiChat": "AI 대화", + "nav.support": "고객 지원", + "nav.mainLabel": "주 메뉴", + "nav.shortcutHint": "Ctrl+{{n}}", + "shell.localAi.connected": "로컬 AI 연결됨", + "shell.localAi.offline": "로컬 AI 꺼짐", + "shell.localAi.checking": "로컬 AI 확인 중", + "shell.localAi.setup": "켜는 방법", + "shell.live.caption": "자막 켜짐", + "shell.live.meeting": "회의 녹음 중", + "shell.rewardClaimed": "클라우드 다듬기 {{tokens}}회가 충전되었습니다.", + "home.title": "오늘", + "home.dictationHint": "누른 채 말하면 받아쓰고, 떼면 커서 자리에 넣습니다.", + "home.dictationUnbound": "받아쓰기 단축키가 없습니다.", + "home.setShortcut": "단축키 설정", + "home.action.newMeeting": "새 회의", + "home.action.captionStart": "실시간 자막 켜기", + "home.action.captionStop": "자막 끄기", + "home.action.captionStarting": "자막 준비 중", + "home.action.captionBlocked": "회의 녹음 중에는 회의가 자막을 씁니다.", + "home.recentRecords": "최근 기록", + "home.recentMeetings": "최근 회의", + "home.viewAll": "모두 보기", + "home.emptyRecords": "아직 기록이 없습니다.", + "home.emptyMeetings": "아직 회의가 없습니다.", + "home.summary.sessions": "오늘 {{count}}회", + "home.summary.words": "{{count}}단어", + "home.summary.minutes": "녹음 {{time}}", + "home.summary.streak": "연속 {{count}}일", + "home.summary.label": "오늘 사용 요약", + "home.fileTranscription": "파일 전사", + "home.status.llmOffline": "로컬 AI(Ollama)가 꺼져 있어 다듬기·요약·회의록을 만들 수 없습니다. 받아쓰기는 그대로 됩니다.", + "home.status.openGuide": "켜는 방법", + "home.usage.title": "오늘 사용량", + "home.usage.unlimited": "무제한", + "home.meetingDuration": "{{time}}", + "home.meetingInProgress": "진행 중", + "record.mode.dictation": "받아쓰기", + "record.mode.caption": "자막", + "record.mode.file": "파일", + "record.mode.command": "명령", + "record.mode.translate": "번역", + "record.mode.conversation": "대화", + "license.feature.premium_llm": "클라우드 AI 다듬기", + "license.feature.cloud_sync": "기기 간 동기화", + "license.feature.team_workspace": "팀 공유 공간", + "license.team": "팀", + "license.enterprise": "엔터프라이즈", + "common.clear": "지우기", + "common.undo": "되돌리기", + "common.deleted": "삭제했습니다.", + "common.copied": "복사했습니다.", + "voice.error.micOpen": "마이크를 열지 못했습니다. 설정 → 오디오에서 입력 장치를 확인하세요.", + "voice.error.micLost": "녹음 중 마이크 연결이 끊겼습니다.", + "voice.error.noSpeech": "말소리가 들리지 않았습니다. 마이크 입력을 확인하세요.", + "voice.error.tooShort": "너무 짧아서 받아쓰지 않았습니다.", + "voice.error.transcription": "받아쓰기에 실패했습니다. 다시 시도해 주세요.", + "voice.error.captionActive": "실시간 자막이 켜져 있어 받아쓰기를 시작할 수 없습니다.", + "voice.error.llmFailed": "AI 다듬기에 실패해 텍스트를 넣지 못했습니다.", + "popup.tip.processing": "받아쓰는 중", + "popup.tip.waitingEngine": "음성 엔진 준비 중", + "popup.tip.inserted": "입력함", + "popup.tip.cancelHint": "Esc 취소", + "popup.tip.recordingLabel": "녹음 중", + "popup.close": "닫기", + "records.searchPlaceholder": "기록 검색 — 본문·제목·요약", + "records.filter.label": "기록 필터", + "records.filter.all": "전체", + "records.filter.commandTranslate": "명령·번역", + "records.filter.favorites": "즐겨찾기만", + "records.filter.clear": "필터 지우기", + "records.tags.label": "태그", + "records.tags.showAll": "태그 {{count}}개 더 보기", + "records.tags.showLess": "태그 접기", + "records.empty.title": "아직 기록이 없습니다", + "records.empty.description": "받아쓰기 단축키를 누르고 말하면, 받아쓴 글이 여기에 날짜별로 쌓입니다.", + "records.empty.shortcut": "받아쓰기 단축키", + "records.empty.unbound": "받아쓰기 단축키가 아직 없습니다.", + "records.empty.setShortcut": "단축키 설정", + "records.noResults.title": "결과 없음", + "records.noResults.description": "검색어나 필터를 바꿔 보세요.", + "records.noResults.partial": "불러온 기록 {{count}}건에서는 찾지 못했습니다. 더 오래된 기록에서 계속 찾을 수 있습니다.", + "records.searchOlder": "이전 기록에서 계속 찾기", + "records.loadMore": "더 불러오기", + "records.loadingMore": "불러오는 중", + "records.loading": "기록을 불러오는 중", + "records.shown": "{{total}}건 중 {{shown}}건 불러옴", + "records.loadError.title": "기록을 불러오지 못했습니다", + "records.loadError.description": "기록 저장소를 읽는 중 문제가 생겼습니다. 다시 시도하고, 계속되면 앱을 다시 시작하세요.", + "records.loadMoreError": "다음 기록을 불러오지 못했습니다.", + "records.status.cancelled": "취소됨", + "records.status.error": "오류", + "records.section.polished": "다듬은 글", + "records.section.original": "원문", + "records.showOriginal": "원문 보기", + "records.hideOriginal": "원문 숨기기", + "records.action.copyOriginal": "원문 복사", + "records.action.copyFailed": "복사하지 못했습니다. 클립보드 접근이 막혀 있을 수 있습니다.", + "records.action.removeTag": "#{{tag}} 태그 떼기", + "records.deleteFailed": "기록을 삭제하지 못해 목록에 되돌려 놓았습니다. 다시 시도해 보세요.", + "records.favoriteFailed": "즐겨찾기를 바꾸지 못했습니다. 다시 시도해 보세요.", + "records.more": "더보기", + "records.clearAllAction": "기록 모두 삭제", + "records.clearAllTitle": "기록을 모두 삭제할까요?", + "records.clearAllBody": "기록 {{count}}건이 모두 사라집니다. 기기 간 동기화를 켰다면 다른 기기에서도 지워집니다. 되돌릴 수 없습니다.", + "records.clearAllIgnoresFilter": "지금 걸린 검색·필터와 상관없이 모든 기록을 지웁니다.", + "records.clearAllConfirm": "모두 삭제", + "records.clearAllDone": "기록을 모두 삭제했습니다.", + "records.clearAllFailed": "기록을 모두 삭제하지 못했습니다. 다시 시도해 보세요.", + "records.exportHint": "태그가 붙은 기록을 태그별 Markdown 파일로 저장합니다. 태그를 골랐으면 그 태그만 저장합니다.", + "records.exportDone": "Markdown 파일로 저장했습니다: {{path}}", + "records.exportFailed": "내보내지 못했습니다. 다시 시도해 보세요.", + "license.section.plan": "요금제", + "license.trial.title": "Pro+ 체험 중", + "license.trial.remaining": "{{days}}일 남았습니다. 체험이 끝나면 무료 등급으로 바뀝니다.", + "license.action.subscribePro": "Pro 구독", + "license.action.subscribeProPlus": "Pro+ 구독", + "license.action.upgradeProPlus": "Pro+로 업그레이드", + "license.plan.active": "{{plan}}을 사용 중입니다.", + "license.teamPlan": "팀 플랜", + "license.enterprisePlan": "엔터프라이즈 플랜", + "license.freePlan": "무료 플랜", + "license.key.title": "라이선스 키", + "license.key.desc": "발급받은 오프라인 라이선스 키(D3RO-LIC-…)를 붙여 넣고 등록하세요.", + "license.key.activate": "등록", + "license.key.deactivate": "해제", + "license.key.activated": "라이선스 키를 등록했습니다.", + "license.key.invalid": "라이선스 키를 확인하지 못했습니다. 발급받은 키 전체를 그대로 붙여 넣었는지 확인하세요.", + "license.key.failed": "등록하지 못했습니다: {{error}}", + "license.key.deactivated": "라이선스를 해제했습니다. 무료 등급으로 바뀌었습니다.", + "license.quotaPerDay": "하루 {{limit}}회", + "license.quotaPerWeek": "주 {{limit}}회", + "cloudSync.account": "계정", + "cloudSync.signInDesc": "Google 또는 GitHub 계정으로 로그인하면 동기화를 시작합니다.", + "cloudSync.status": "동기화 상태", + "support.title": "고객지원", + "support.tab.faq": "자주 묻는 질문", + "support.tab.contact": "문의하기", + "support.faq.search": "질문 검색", + "support.faq.empty": "일치하는 질문이 없습니다.", + "support.faq.mic.q": "마이크 음성 인식이 작동하지 않거나 소리가 입력되지 않습니다.", + "support.faq.mic.a": "1. Windows 설정 > 개인 정보 및 보안 > 마이크에서 \"데스크톱 앱이 마이크에 액세스하도록 허용\"이 켜져 있는지 확인하세요.\n2. D3RO Voice 설정 > 오디오 탭에서 실제로 쓰는 입력 마이크가 선택되었는지 확인하세요.\n3. 그래도 해결되지 않으면 \"문의하기\" 탭에서 이메일로 알려 주세요.", + "support.faq.gpu.q": "NVIDIA CUDA GPU 가속을 어떻게 켜나요?", + "support.faq.gpu.a": "NVIDIA 그래픽 카드와 CUDA 런타임이 준비되어 있으면 faster-whisper(CTranslate2) 엔진이 GPU를 감지해 GPU 모드로 동작합니다. 큰 모델일수록 VRAM이 더 필요합니다.", + "support.faq.refund.q": "환불은 어떻게 요청하나요?", + "support.faq.refund.a": "환불과 결제 취소는 구매 화면에 안내된 조건과 해당 결제 처리자(Google Play 등)의 정책을 따릅니다. 앱 안에서 자동으로 환불되지는 않습니다. \"문의하기\" 탭에서 \"결제 / 환불\" 분류로 이메일을 보내 주시면 확인 후 답변드립니다.", + "support.faq.invoice.q": "법인 전자세금계산서 발행은 어떻게 요청하나요?", + "support.faq.invoice.a": "\"문의하기\" 탭에서 \"결제 / 환불\" 분류로 사업자 정보와 결제 내역을 적어 이메일을 보내 주세요.", + "support.category.audio": "마이크 / 오디오", + "support.category.gpu": "CUDA GPU / 모델 로딩 오류", + "support.category.billing": "결제 / 환불", + "support.category.general": "기타 문의", + "support.contact.intro": "문의는 이메일로 받습니다. 아래 내용을 채우고 버튼을 누르면 메일 앱에 초안이 열립니다. 메일 앱에서 직접 보내야 문의가 전달됩니다.", + "support.contact.category": "분류", + "support.contact.subject": "문의 제목", + "support.contact.subjectPlaceholder": "문제를 한 줄로 요약해 주세요", + "support.contact.details": "상세 내용", + "support.contact.detailsPlaceholder": "어떤 상황에서 문제가 생겼는지 적어 주세요", + "support.contact.required": "필수 항목입니다.", + "support.contact.diagnostics": "메일에 함께 담기는 진단 정보", + "support.contact.submit": "메일 앱에서 작성", + "support.contact.opened": "메일 앱에 초안을 열었습니다. 메일 앱에서 보내기를 눌러야 문의가 전달됩니다.", + "support.contact.openFailed": "메일 앱을 열지 못했습니다. 아래 주소로 직접 이메일을 보내 주세요.", + "support.contact.address": "지원 이메일", + "ads.label": "광고", + "ads.testAd": "테스트 광고", + "ads.learnMore": "자세히 보기", + "ads.removeWithPro": "Pro로 광고 없애기", + "ads.rewarded.title": "광고 보고 충전하기", + "ads.rewarded.unavailableTitle": "볼 수 있는 광고가 없습니다", + "ads.rewarded.checking": "확인된 광고를 찾는 중…", + "ads.rewarded.notAvailable": "지금은 확인된 광고가 없습니다. 잠시 후 다시 시도하세요.", + "ads.rewarded.unreachable": "광고 제공자에 연결하지 못했습니다. 인터넷 연결을 확인하고 다시 시도하세요.", + "ads.rewarded.verifyFailed": "시청을 확인하지 못해 충전하지 않았습니다. 다시 시도하세요.", + "ads.rewarded.verifyNote": "서버가 시청을 확인한 뒤에만 충전됩니다.", + "ads.rewarded.ready": "시청 완료", + "ads.rewarded.remaining": "{{seconds}}초 남음", + "ads.rewarded.claim": "충전하기", + "ads.rewarded.progress": "광고 재생 진행률", + "onboarding.wizard.title": "D3RO Voice 시작하기", + "onboarding.wizard.progress": "설정 진행", + "onboarding.wizard.skip": "나중에 설정", + "onboarding.wizard.skipHint": "건너뛰면 기본값(로컬 모드)으로 시작합니다. 설정 › 정보에서 이 안내를 다시 열 수 있습니다.", + "onboarding.mode.intro": "음성 인식과 AI 처리를 어디서 할지 고르세요. 설정에서 언제든 바꿀 수 있습니다.", + "onboarding.mode.local.title": "로컬 모드", + "onboarding.mode.local.badge": "권장", + "onboarding.mode.local.desc": "Ollama와 로컬 Whisper로 이 PC 안에서 음성 인식과 AI 다듬기를 합니다.", + "onboarding.mode.local.point1": "인터넷 연결 없이 동작합니다", + "onboarding.mode.local.point2": "음성 인식과 AI 처리가 이 PC 안에서 끝납니다", + "onboarding.mode.local.point3": "API 요금이 없습니다 (Ollama 설치와 모델 다운로드 필요)", + "onboarding.mode.local.action": "로컬 모드로 시작", + "onboarding.mode.online.title": "온라인 모드", + "onboarding.mode.online.desc": "D3RO 계정으로 로그인하면 서버의 음성 인식과 AI 모델을 씁니다.", + "onboarding.mode.online.point1": "로컬 모델을 설치하지 않아도 됩니다", + "onboarding.mode.online.point2": "기기 간 동기화를 쓸 수 있습니다", + "onboarding.mode.online.point3": "D3RO 계정이 필요합니다", + "onboarding.mode.online.action": "계정으로 로그인", + "onboarding.local.connected": "Ollama에 연결됨", + "onboarding.local.connectedVersion": "Ollama에 연결됨 (v{{version}})", + "onboarding.local.disconnected": "Ollama에 연결되지 않음", + "onboarding.local.connectedDetail": "설치된 모델 {{count}}개 · 사용 모델 {{model}}", + "onboarding.local.disconnectedDetail": "Ollama가 설치되지 않았거나 실행 중이 아닙니다.", + "onboarding.local.recheck": "다시 확인", + "onboarding.local.start": "Ollama 실행", + "onboarding.local.installTitle": "Ollama를 아직 설치하지 않았다면", + "onboarding.local.installStep1": "1. ollama.com에서 Ollama를 내려받아 설치합니다.", + "onboarding.local.installStep2": "2. 설치한 뒤 '다시 확인'을 누르면 D3RO Voice가 Ollama에 연결합니다.", + "onboarding.local.openDownload": "ollama.com/download 열기", + "onboarding.local.modelTitle": "기본 AI 모델 받기 ({{model}})", + "onboarding.local.modelDesc": "받아쓰기 다듬기·요약·번역에 쓰는 기본 모델입니다. Ollama에 연결되면 받을 수 있습니다.", + "onboarding.local.pull": "{{model}} 받기", + "onboarding.local.modelReady": "AI 모델이 이미 설치되어 있습니다.", + "onboarding.local.finish": "설정 마치기", + "onboarding.local.finishHint": "Ollama에 연결되면 설정을 마칠 수 있습니다.", + "onboarding.auth.intro": "D3RO 계정으로 로그인하면 온라인 모드로 시작합니다.", + "onboarding.auth.email": "이메일", + "onboarding.auth.password": "비밀번호", + "onboarding.auth.submitLogin": "로그인", + "onboarding.auth.submitRegister": "가입하기", + "onboarding.auth.processing": "처리 중…", + "onboarding.auth.failed": "로그인하지 못했습니다. 이메일과 비밀번호를 확인하세요.", + "onboarding.auth.error": "로그인하지 못했습니다: {{message}}", + "onboarding.done.title": "설정을 마쳤습니다", + "onboarding.done.desc": "STT 공급자와 Ollama 모델은 설정 화면에서 바꿀 수 있습니다.", + "onboarding.done.start": "시작하기", + "onboarding.failedPhase": "설정하지 못했습니다. {{message}}", + "ollamaGuide.start.running": "Ollama가 이미 실행 중입니다.", + "ollamaGuide.start.starting": "Ollama 서버를 시작했습니다. 연결을 확인하는 중입니다.", + "ollamaGuide.start.notInstalled": "Ollama가 설치되어 있지 않습니다. ollama.com에서 내려받아 설치하세요.", + "ollamaGuide.start.failed": "Ollama를 실행하지 못했습니다. Ollama를 직접 실행한 뒤 '다시 확인'을 누르세요.", + "ollamaGuide.title": "Ollama 설정", + "ollamaGuide.status.connected": "Ollama에 연결됨", + "ollamaGuide.status.connectedVersion": "Ollama에 연결됨 (v{{version}})", + "ollamaGuide.status.disconnected": "Ollama에 연결되지 않음", + "ollamaGuide.status.detail": "서버 {{url}} · 사용 모델 {{model}}", + "ollamaGuide.recheck": "다시 확인", + "ollamaGuide.start": "Ollama 실행", + "ollamaGuide.starting": "실행 중…", + "ollamaGuide.step1.title": "1. Ollama 설치와 실행", + "ollamaGuide.step1.desc": "Ollama는 이 PC에서 LLM을 실행하는 무료 오픈소스 프로그램입니다. 설치한 뒤 실행하면 D3RO Voice가 연결합니다.", + "ollamaGuide.step1.download": "ollama.com에서 내려받기", + "ollamaGuide.step2.title": "2. 모델 받기", + "ollamaGuide.step2.desc": "받아쓰기 다듬기·번역·요약에 쓸 모델을 받습니다. Ollama에 연결되어 있어야 받을 수 있습니다.", + "ollamaGuide.step2.installedCount": "설치된 모델 {{count}}개", + "ollamaGuide.model.recommended": "권장", + "ollamaGuide.model.gemma4e4b": "이 앱의 기본 모델입니다. 추론(thinking) 토큰을 쓰지 않아 받아쓰기 다듬기 응답이 빠릅니다.", + "ollamaGuide.model.llama32": "Meta의 경량 모델입니다. 문법 교정과 어조 조절에 씁니다.", + "ollamaGuide.model.qwen25": "한국어를 포함한 여러 언어를 지원하는 경량 모델입니다.", + "ollamaGuide.model.phi4": "요약·번역에 쓰는 큰 모델입니다. RAM 16GB 이상을 권장합니다.", + "ollamaGuide.model.inUse": "사용 중", + "ollamaGuide.model.select": "기본 모델로 선택", + "ollamaGuide.model.pull": "받기", + "ollamaGuide.model.pullAria": "{{model}} 받기", + "ollamaGuide.terminalHint": "터미널에서 직접 받으려면 다음 명령을 실행하세요.", + "ollamaGuide.step3.title": "3. 동작 확인", + "ollamaGuide.step3.desc": "문장을 입력하고 실행하면 사용 중인 모델이 문장을 다듬어 돌려줍니다.", + "ollamaGuide.test.sample": "안녕하세요 오늘의 날씨를 알려줘", + "ollamaGuide.test.placeholder": "다듬을 문장 입력", + "ollamaGuide.test.inputLabel": "테스트 문장", + "ollamaGuide.test.run": "다듬기 실행", + "ollamaGuide.test.running": "처리 중", + "ollamaGuide.test.result": "다듬은 결과 ({{model}})", + "ollamaGuide.test.noResponse": "모델이 응답하지 않았습니다.", + "ollamaGuide.test.error": "실행하지 못했습니다: {{message}}", + "ollamaGuide.footer": "Ollama를 쓰면 AI 처리가 이 PC 안에서 끝납니다.", + "codexGuide.title": "OpenAI·Codex 연결 안내", + "codexGuide.tab.apiKey": "OpenAI API 키", + "codexGuide.tab.codex": "Codex·ChatGPT 구독", + "codexGuide.tab.providers": "다른 STT 서비스 키", + "codexGuide.apiKey.introTitle": "OpenAI API 키로 연결하기", + "codexGuide.apiKey.introDesc": "OpenAI Whisper API는 사용한 시간만큼 분 단위로 요금이 청구되며, ChatGPT 구독과는 별도로 결제됩니다. 요금은 OpenAI 요금 페이지에서 확인하세요.", + "codexGuide.apiKey.step1.title": "1. OpenAI Platform의 API Keys 페이지를 엽니다", + "codexGuide.apiKey.step1.desc": "OpenAI 계정으로 로그인하면 API 키 목록이 보입니다.", + "codexGuide.apiKey.step1.action": "API Keys 열기", + "codexGuide.apiKey.step2.title": "2. 새 비밀 키를 만들고 복사합니다", + "codexGuide.apiKey.step2.desc": "'Create new secret key'를 누르고 이름을 입력하면 sk-proj-로 시작하는 키가 만들어집니다.", + "codexGuide.apiKey.step2.warning": "키는 만들 때 한 번만 보이므로 바로 복사하세요.", + "codexGuide.apiKey.step3.title": "3. STT 설정에 붙여넣고 연결을 테스트합니다", + "codexGuide.apiKey.step3.desc": "설정 › STT에서 OpenAI를 고르고 API 키 칸에 붙여넣은 뒤 '연결 테스트'를 누르면 키가 동작하는지 확인합니다.", + "codexGuide.codex.introTitle": "ChatGPT Plus·Pro·Team 구독자 안내", + "codexGuide.codex.introDesc": "ChatGPT 구독과 OpenAI Platform API는 서로 다른 계정 체계입니다. Codex CLI는 브라우저 OAuth(Device Flow)로 토큰을 받아 인증합니다.", + "codexGuide.codex.step1.title": "1. 터미널에서 Device Auth 로그인을 실행합니다", + "codexGuide.codex.step1.desc": "화면에 표시된 8자리 인증 코드를 브라우저 인증 페이지에 입력합니다.", + "codexGuide.codex.step2.title": "2. 연결이 안 될 때", + "codexGuide.codex.step2.proxy": "VPN이나 회사 프록시가 localhost 리디렉션을 막으면 --device-auth 옵션을 쓰세요.", + "codexGuide.codex.step2.expired": "OAuth 토큰이 만료되면 다시 로그인하거나 API 키 방식을 쓰세요.", + "codexGuide.copyCommand": "명령 복사", + "codexGuide.providers.intro": "다음 서비스에서 API 키를 발급받아 STT 설정에 등록할 수 있습니다.", + "codexGuide.providers.groq": "Groq 서버에서 Whisper Large V3를 실행합니다. 무료 사용 한도가 있습니다.", + "codexGuide.providers.deepgram": "자동 문장부호와 서식을 지원합니다. 가입하면 무료 크레딧을 줍니다.", + "codexGuide.providers.google": "Google AI Studio에서 API 키를 발급합니다.", + "codexGuide.providers.assemblyai": "AssemblyAI 대시보드에서 API 키를 발급합니다.", + "codexGuide.providers.open": "{{name}} 키 발급", + "records.notFound": "이 기록을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.", + "records.tags.filterAria": "태그로 거르기", + "meetings.header.mic": "마이크 {{name}}", + "meetings.header.engine": "음성 인식 {{name}}", + "meetings.engine.local": "로컬 Whisper", + "meetings.defaultMic": "기본 마이크", + "meetings.starting": "준비 중…", + "meetings.searchPlaceholder": "회의 제목 검색", + "meetings.empty.title": "아직 회의가 없습니다", + "meetings.empty.description": "회의를 녹음하면 전사가 남고, 요약과 문서를 만들 수 있습니다.", + "meetings.noResults.title": "일치하는 회의가 없습니다", + "meetings.noResults.description": "제목으로만 찾습니다. 다른 검색어를 써 보세요.", + "meetings.noResults.partial": "불러온 회의 {{count}}건에서는 찾지 못했습니다. 이전 회의를 더 불러와 찾아보세요.", + "meetings.loadMore": "더 불러오기", + "meetings.loadingMore": "불러오는 중", + "meetings.shown": "{{total}}건 중 {{shown}}건", + "meetings.loadError.title": "회의 목록을 불러오지 못했습니다", + "meetings.loadError.description": "회의 저장소를 읽는 중 문제가 생겼습니다. 다시 시도하고, 계속되면 앱을 다시 시작하세요.", + "meetings.loadMoreError": "다음 회의를 불러오지 못했습니다.", + "meetings.memoCount": "메모 {{count}}", + "meetings.status.recording": "녹음 중", + "meetings.status.processing": "저장 중", + "meetings.status.error": "오류", + "meetings.status.completed": "완료", + "meetings.more": "더 보기", + "meetings.rename": "제목 변경", + "meetings.renameDialog.title": "회의 제목 변경", + "meetings.renameDialog.label": "제목", + "meetings.renameFailed": "제목을 바꾸지 못했습니다: {{message}}", + "meetings.delete.title": "‘{{title}}’ 회의를 삭제할까요?", + "meetings.delete.body": "전사, 회의 메모, 문서, 녹음 파일이 함께 삭제됩니다. 동기화된 다른 기기에서도 사라지며 되돌릴 수 없습니다.", + "meetings.deleted": "회의를 삭제했습니다.", + "meetings.deleteFailed": "회의를 삭제하지 못했습니다: {{message}}", + "meetings.live.title": "회의 녹음", + "meetings.live.stopping": "종료하는 중", + "meetings.live.stopFailed": "녹음을 멈추지 못했습니다: {{message}}", + "meetings.live.transcript": "실시간 전사", + "meetings.live.editHint": "지난 문장을 누르면 바로 고칠 수 있습니다", + "meetings.live.segmentCount": "{{count}}문장", + "meetings.live.waiting": "말하기 시작하면 전사가 여기에 나타납니다.", + "meetings.live.editFailed": "전사 수정을 저장하지 못했습니다: {{message}}", + "meetings.live.memoTitle": "회의 메모", + "meetings.live.memoHint": "메모는 녹음 시각과 함께 저장되고, 요약을 만들 때 먼저 반영됩니다.", + "meetings.live.memoPlaceholder": "메모 입력 후 Enter", + "meetings.live.memoFailed": "메모를 저장하지 못했습니다: {{message}}", + "meetings.live.level": "입력 레벨", + "meetings.live.elapsed": "경과 시간", + "meetings.tag.decision": "#결정", + "meetings.tag.todo": "#할일", + "meetings.tag.agenda": "#안건", + "meetings.tag.idea": "#아이디어", + "meetings.tag.issue": "#이슈", + "meetings.detail.back": "회의 목록", + "meetings.detail.copySummary": "요약 복사", + "meetings.detail.copyTranscript": "전사 복사", + "meetings.detail.summaryCopied": "요약을 복사했습니다.", + "meetings.detail.transcriptCopied": "전사를 복사했습니다.", + "meetings.detail.copyFailed": "클립보드에 복사하지 못했습니다.", + "meetings.export.label": "내보내기", + "meetings.export.summaryGroup": "요약", + "meetings.export.transcriptGroup": "전사", + "meetings.export.noSummary": "요약을 만든 뒤 내보낼 수 있습니다", + "meetings.export.done": "저장했습니다: {{path}}", + "meetings.export.failed": "내보내지 못했습니다: {{message}}", + "meetings.format.md": "Markdown (.md)", + "meetings.format.txt": "텍스트 (.txt)", + "meetings.format.pdf": "PDF (.pdf)", + "meetings.format.docx": "Word (.docx)", + "meetings.tabs.label": "회의 내용", + "meetings.tab.summary": "요약", + "meetings.tab.transcript": "전사", + "meetings.tab.documents": "문서", + "meetings.tab.notes": "내 메모", + "meetings.ask": "질문하기", + "meetings.askClose": "질문 닫기", + "meetings.askSend": "보내기", + "meetings.summary.empty.title": "아직 요약이 없습니다", + "meetings.summary.empty.description": "전사를 바탕으로 핵심 요약, 결정 사항, 할 일을 정리합니다.", + "meetings.summary.empty.noTranscript": "전사가 없어 요약을 만들 수 없습니다.", + "meetings.summary.create": "요약 만들기", + "meetings.summary.creating": "요약을 만드는 중…", + "meetings.summary.chooseTemplate": "다른 형식으로 만들기", + "meetings.summary.edit": "문서에서 편집", + "meetings.summary.actionItems": "할 일", + "meetings.summary.copyActionItems": "할 일 복사", + "meetings.summary.actionItemsCopied": "할 일을 복사했습니다.", + "meetings.summary.decisions": "결정 사항", + "meetings.summary.generateFailed": "문서를 만들지 못했습니다: {{message}}", + "meetings.doc.new": "새 문서", + "meetings.doc.untitled": "문서 {{n}}", + "meetings.doc.list": "문서 목록", + "meetings.doc.primaryHint": "첫 문서가 요약 탭에 보입니다.", + "meetings.doc.empty.title": "문서가 없습니다", + "meetings.doc.empty.description": "회의록, 보고서, 아이디어 노트 같은 형식을 골라 전사로 문서를 만듭니다.", + "meetings.doc.delete.title": "‘{{title}}’ 문서를 삭제할까요?", + "meetings.doc.delete.body": "이 문서만 삭제되며 되돌릴 수 없습니다. 전사와 회의 메모는 남습니다.", + "meetings.doc.deleted": "문서를 삭제했습니다.", + "meetings.notes.recordedTitle": "녹음 중 남긴 메모", + "meetings.notes.padTitle": "내 메모", + "meetings.notes.padHint": "이 기기에 자동 저장됩니다. 다른 기기와는 동기화되지 않습니다.", + "meetings.notes.placeholder": "논의한 내용, 고객 피드백, 내가 할 일을 자유롭게 적으세요.", + "meetings.notes.enhance": "메모로 회의록 만들기", + "meetings.notes.enhancing": "만드는 중…", + "meetings.notes.enhanceHint": "내 메모와 전사를 합쳐 새 문서를 만듭니다.", + "meetings.notes.enhancedDocTitle": "내 메모로 정리한 회의록", + "meetings.notes.saveFailed": "메모를 이 기기에 저장하지 못했습니다. 저장 공간을 확인하세요.", + "meetings.transcript.empty": "전사된 내용이 없습니다.", + "meetings.transcript.memo": "메모", + "meetings.error.reason": "원인", + "meetings.error.fix": "해결 방법", + "meetings.error.openAudioSettings": "오디오 설정 열기", + "meetings.error.openSttSettings": "음성 인식 설정 열기", + "meetings.error.forceStart": "진행 중인 세션을 정리하고 시작", + "meetings.error.forceRetry": "초기화 후 다시 시도", + "meetings.error.collision.title": "다른 회의나 실시간 자막이 아직 실행 중입니다", + "meetings.error.collision.message": "이전 회의 세션이나 자막이 백그라운드에서 계속 실행 중입니다.", + "meetings.error.collision.reason": "이전 회의·자막 프로세스가 완전히 끝나지 않아 새 녹음과 부딪혔습니다.", + "meetings.error.collision.suggestion": "실행 중인 세션을 정리하면 바로 새 회의를 시작할 수 있습니다.", + "meetings.error.stt.title": "음성 인식(STT) 엔진을 준비하지 못했습니다", + "meetings.error.stt.message": "음성 인식 엔진이나 Whisper 모델을 불러오지 못했습니다.", + "meetings.error.stt.reason": "로컬 Whisper 모델 파일이 준비되지 않았거나 사이드카 프로세스 포트가 사용 중입니다.", + "meetings.error.stt.suggestion": "음성 인식 설정에서 모델 다운로드 상태를 확인하거나, 초기화 후 다시 시도하세요.", + "meetings.error.audio.title": "마이크 녹음을 시작하지 못했습니다", + "meetings.error.audio.message": "마이크 오디오 스트림을 열 수 없습니다.", + "meetings.error.audio.reason": "기본 마이크를 찾을 수 없거나 Windows 마이크 권한이 꺼져 있습니다.", + "meetings.error.audio.suggestion": "오디오 설정에서 마이크를 고르거나, 기본 장치로 다시 시도하세요.", + "meetings.error.processing.title": "회의를 저장하지 못했습니다", + "meetings.error.processing.message": "녹음을 마친 뒤 전사를 저장하는 중 오류가 났습니다.", + "meetings.error.processing.reason": "녹음 종료 후 회의 기록을 데이터베이스에 쓰지 못했습니다.", + "meetings.error.processing.suggestion": "실패한 회의는 목록에 오류 상태로 남습니다. 디스크 공간을 확인한 뒤 목록에서 그 회의를 열어 확인하세요.", + "meetings.error.general.title": "회의를 시작할 수 없습니다", + "meetings.error.general.message": "알 수 없는 오류로 회의 녹음을 시작하지 못했습니다.", + "meetings.error.general.reason": "백그라운드 통신이나 초기화 중 문제가 생겼습니다.", + "meetings.error.general.suggestion": "초기화한 뒤 다시 시작해 보세요.", + "popup.result.title": "입력하지 못했습니다 · 복사해서 붙여넣으세요", + "popup.copied": "복사함", + "popup.hints.move": "이동", + "popup.hints.paste2": "붙여넣기", + "popup.hints.apply2": "적용", + "popup.hints.close2": "닫기", + "popup.hints.pick": "번호로 고르기", + "popup.history.title": "최근 받아쓰기", + "popup.history.empty": "아직 받아쓴 글이 없습니다.", + "popup.command.title": "명령 고르기", + "popup.command.empty": "만든 명령이 없습니다.", + "popup.command.none": "명령 없이 원문 넣기", + "settings.closeToTray.desc": "창을 닫아도 트레이에서 계속 실행되어 단축키가 동작합니다.", + "settings.autoLaunch.desc": "컴퓨터에 로그인하면 D3RO Voice를 시작합니다.", + "settings.autoInsert.desc": "전사가 끝나면 커서가 있는 앱에 텍스트를 바로 넣습니다.", + "settings.soundEffects.desc": "녹음을 시작하고 끝낼 때 소리로 알립니다.", + "settings.micTest": "마이크 테스트", + "settings.micTest.desc": "테스트를 누르고 말하면 막대가 움직입니다. 움직이지 않으면 다른 입력 장치를 고르세요.", + "settings.micTest.level": "입력 레벨", + "settings.micTest.failed": "마이크를 열지 못했습니다. 다른 앱이 마이크를 쓰고 있는지, Windows 마이크 권한이 켜져 있는지 확인하세요.", + "settings.llm.engineSection": "AI 처리 엔진", + "settings.llm.engine": "처리 위치", + "settings.llm.engine.desc": "받아쓰기 다듬기·요약·명령어를 어디서 처리할지 고릅니다.", + "settings.llm.engine.local": "로컬 Ollama — 이 PC에서 실행, 인터넷 없이 동작", + "settings.llm.engine.online": "D3RO 온라인 API — 로그인 필요", + "settings.llm.ollamaStatus": "Ollama 연결", + "settings.llm.ollamaChecking": "연결을 확인하는 중", + "settings.llm.ollamaConnected": "연결됨 · 설치된 모델 {{count}}개", + "settings.llm.ollamaConnectedVersion": "연결됨 · v{{version}} · 설치된 모델 {{count}}개", + "settings.llm.ollamaOffline": "연결되지 않음 — Ollama 서버를 실행해야 로컬 AI 처리가 동작합니다.", + "settings.llm.checkConnection": "연결 확인", + "settings.llm.startOllama": "Ollama 실행", + "settings.llm.startingOllama": "실행하는 중", + "settings.llm.guide": "설치와 모델", + "settings.llm.guide.desc": "Ollama 설치 방법과 받을 수 있는 모델을 안내합니다.", + "settings.llm.openGuide": "안내 열기", + "settings.llm.feedback.running": "Ollama가 이미 실행 중입니다.", + "settings.llm.feedback.started": "Ollama 서버를 시작했습니다. 잠시 후 연결을 다시 확인합니다.", + "settings.llm.feedback.notInstalled": "Ollama가 설치되어 있지 않습니다. 설치 안내를 열어 설치하세요.", + "settings.llm.feedback.failed": "Ollama를 실행하지 못했습니다. 설치 안내를 확인하세요.", + "settings.llm.serverUrl.desc": "Ollama가 다른 주소에서 실행 중일 때만 바꿉니다.", + "settings.llm.activeModel": "사용할 모델", + "settings.llm.activeModel.desc": "다듬기·요약·대화에 쓰는 Ollama 모델입니다.", + "settings.llm.modelRecommended": "{{name}} (권장 · 설치 안 됨)", + "settings.llm.modelNotInstalled": "{{name}} (설치 안 됨)", + "settings.llm.onlineUrl": "API 서버 URL", + "settings.llm.account": "계정", + "settings.llm.accountSignedIn": "{{email}}(으)로 로그인됨", + "settings.llm.accountSignedInNoEmail": "로그인됨", + "settings.llm.accountSignedOut": "로그인하지 않음 — 라이선스 탭에서 로그인해야 온라인 API를 쓸 수 있습니다.", + "settings.about.appName": "D3RO Voice", + "settings.about.summary": "음성 인식과 AI 처리를 이 PC에서 실행하는 음성 어시스턴트입니다. 온라인 API와 기기 간 동기화는 켠 경우에만 인터넷을 씁니다.", + "settings.about.helpSection": "도움말", + "settings.about.restartOnboarding.desc": "처음 실행할 때 본 안내를 다시 엽니다. 앱 화면이 새로 고쳐집니다.", + "settings.about.restartOnboarding.action": "안내 열기", + "settings.stt.engineSection": "음성 인식 엔진", + "settings.stt.openGuide": "OpenAI · Codex 연결 안내", + "settings.stt.engine": "엔진", + "settings.stt.provider.local.desc": "이 PC에서 Whisper로 인식합니다. 인터넷 없이 동작하고 오디오를 밖으로 보내지 않습니다.", + "settings.stt.provider.d3roCloud.desc": "D3RO 서버에서 인식합니다. D3RO 계정으로 인증하므로 API 키가 필요 없습니다.", + "settings.stt.provider.openai.desc": "오디오를 OpenAI 서버로 보내 인식합니다. OpenAI API 키가 필요합니다.", + "settings.stt.provider.groq.desc": "오디오를 Groq 서버로 보내 인식합니다. 응답이 빠르고 Groq API 키가 필요합니다.", + "settings.stt.provider.deepgram.desc": "오디오를 Deepgram 서버로 보내 인식합니다. 문장 부호와 서식을 자동으로 넣습니다. API 키가 필요합니다.", + "settings.stt.provider.assemblyai.desc": "오디오를 AssemblyAI 서버로 보내 인식합니다. API 키가 필요합니다.", + "settings.stt.provider.google.desc": "오디오를 Google Gemini로 보내 인식합니다. 한국어를 포함한 여러 언어를 지원합니다. API 키가 필요합니다.", + "settings.stt.provider.custom.desc": "OpenAI 호환 엔드포인트(vLLM, 사내 서버 등)로 보냅니다. 주소는 고급에서 넣습니다.", + "settings.stt.modelInstalled": "설치됨", + "settings.stt.modelNotDownloaded": "받지 않음", + "settings.stt.modelNeedsDownload": "{{name}} 모델을 아직 받지 않았습니다 · 약 {{size}} MB", + "settings.stt.downloadModel": "모델 받기", + "settings.stt.downloading": "받는 중 {{percent}}%", + "settings.stt.runtime.sidecar": "로컬 음성 엔진 (faster-whisper)", + "settings.stt.runtime.ffmpeg": "미디어 변환기 (ffmpeg)", + "settings.stt.runtime.installed": "설치됨 · {{size}} MB", + "settings.stt.runtime.missing": "설치되지 않음 — 로컬 전사에 필요합니다", + "settings.stt.runtime.install": "내려받기", + "settings.stt.runtime.reinstall": "다시 설치", + "settings.stt.apiKey": "API 키", + "settings.stt.apiKey.desc": "{{name}}에서 발급한 키를 붙여 넣으세요. 연결 테스트로 키가 맞는지 확인할 수 있습니다.", + "settings.stt.apiKeyPlaceholder": "API 키 붙여 넣기", + "settings.stt.account": "D3RO 계정", + "settings.stt.account.desc": "로그인한 D3RO 계정으로 인증합니다. 연결 테스트로 사용할 수 있는지 확인하세요.", + "settings.stt.showKey": "키 보기", + "settings.stt.hideKey": "키 숨기기", + "settings.stt.testConnection": "연결 테스트", + "settings.stt.testing": "테스트하는 중", + "settings.stt.languageSection": "언어", + "settings.stt.advancedSection": "고급", + "settings.stt.advanced.desc": "모델 ID · 엔드포인트 · 실패 시 전환", + "settings.stt.advanced.show": "펼치기", + "settings.stt.advanced.hide": "접기", + "settings.stt.modelId": "모델 ID", + "settings.stt.modelId.desc": "기본값: {{model}}", + "settings.stt.modelId.auto": "자동", + "settings.stt.baseUrl": "API 엔드포인트 URL", + "settings.stt.baseUrl.desc": "프록시나 사내 서버를 쓸 때만 바꿉니다.", + "tools.builtin": "기본 제공", + "tools.editItem": "{{name}} 편집", + "tools.deleteItem": "{{name}} 삭제", + "tools.deleteFailed": "삭제하지 못했습니다. 항목을 되돌렸습니다 — 잠시 뒤 다시 시도하세요.", + "tools.copyFailed": "복사하지 못했습니다. 클립보드 권한을 확인하세요.", + "tools.loadMore": "더 보기 ({{shown}}/{{total}})", + "tools.commands.description": "받아쓴 문장을 AI가 어떻게 다듬을지 고릅니다.", + "tools.commands.add": "명령 추가", + "tools.commands.listTitle": "사용할 명령", + "tools.commands.listHint": "하나를 고르면 다음 받아쓰기부터 적용됩니다.", + "tools.commands.none": "AI 명령 사용 안 함", + "tools.commands.noneHint": "받아쓴 문장을 그대로 넣습니다.", + "tools.commands.inUse": "사용 중", + "tools.commands.empty": "만든 명령이 없습니다", + "tools.commands.emptyHint": "명령 추가를 눌러 자주 쓰는 다듬기 방식을 저장하세요.", + "tools.commands.loadFailed": "명령 목록을 불러오지 못했습니다.", + "tools.commands.selectFailed": "명령을 바꾸지 못했습니다. 이전 설정으로 되돌렸습니다.", + "tools.commands.deleted": "'{{name}}' 명령을 삭제했습니다.", + "tools.commands.action.expand": "늘려 쓰기", + "tools.commands.action.chain": "체인", + "tools.commands.pipeline.current": "지금 받아쓰기 후처리:", + "tools.commands.pipeline.missing": "명령이 선택되지 않음", + "tools.commands.pipeline.stepsLabel": "받아쓰기 단계", + "tools.commands.pipeline.stepStt": "음성을 글로 옮깁니다. 사전 단어를 인식 힌트로 씁니다.", + "tools.commands.pipeline.stepRaw": "AI 후처리 없이 그대로 둡니다.", + "tools.commands.pipeline.stepInstruction": "AI가 '{{name}}' 명령으로 다듬습니다.", + "tools.commands.pipeline.stepMissing": "명령을 쓰도록 설정됐지만 고른 명령이 없습니다 — 아래에서 하나를 고르세요.", + "tools.commands.pipeline.stepAction": "AI가 기본 후처리({{action}})를 적용합니다. 설정 > LLM에서 바꿀 수 있습니다.", + "tools.commands.pipeline.stepInsert": "결과를 지금 쓰던 창에 넣습니다.", + "tools.commands.test.title": "명령 시험하기", + "tools.commands.test.description": "받아쓰기와 같은 경로로 문장 하나를 처리해 결과를 미리 봅니다.", + "tools.commands.test.placeholder": "받아쓴 것처럼 문장을 입력하세요", + "tools.commands.test.inputLabel": "시험할 문장", + "tools.commands.test.run": "실행", + "tools.commands.test.running": "실행 중…", + "tools.commands.test.result": "결과", + "tools.commands.test.failed": "처리하지 못했습니다: {{reason}}", + "tools.commands.test.blockedRaw": "AI 후처리를 쓰지 않는 설정이라 결과가 입력과 같습니다.", + "tools.commands.test.blockedChain": "체인은 여러 단계를 거치므로 여기서 시험할 수 없습니다.", + "tools.commands.test.blockedMissing": "먼저 사용할 명령을 고르세요.", + "tools.templates.description": "항목을 차례로 불러 주면 정해진 양식으로 채워 넣습니다.", + "tools.templates.emptyHint": "새 템플릿을 눌러 자주 쓰는 양식을 만드세요.", + "tools.templates.fieldCount": "필드 {{count}}개", + "tools.templates.lastOutput": "마지막 결과", + "tools.templates.deleted": "'{{name}}' 템플릿을 삭제했습니다.", + "tools.templates.startItem": "{{name}} 시작", + "tools.templates.removeField": "{{n}}번 필드 삭제", + "tools.dictionary.description": "받아쓰기가 이 단어들을 먼저 알아듣도록 인식 힌트로 씁니다(최대 50개).", + "tools.dictionary.add": "단어 추가", + "tools.dictionary.search": "단어 검색", + "tools.dictionary.empty": "등록한 단어가 없습니다", + "tools.dictionary.emptyHint": "이름·전문 용어처럼 자주 틀리게 받아써지는 단어를 추가하세요.", + "tools.dictionary.noResultsHint": "다른 검색어로 찾아보세요.", + "tools.dictionary.loadFailed": "사전을 불러오지 못했습니다.", + "tools.dictionary.deleted": "'{{word}}'을(를) 삭제했습니다.", + "tools.dictionary.pronunciationValue": "발음 {{value}}", + "tools.dictionary.pronunciationHelp": "예: 디쓰리오 — 지금은 참고용으로 저장되며, 인식 힌트에는 단어만 쓰입니다.", + "tools.dictionary.formatJson": "JSON 파일", + "tools.dictionary.formatCsv": "CSV 파일", + "tools.knowledge.description": "추가한 문서에 질문하면 관련 부분을 찾아 답합니다. 문서는 이 기기에서 색인됩니다.", + "tools.knowledge.ask": "질문", + "tools.knowledge.examples": "예시", + "tools.knowledge.example.summary": "이 문서들의 핵심 내용을 세 줄로 요약해 줘", + "tools.knowledge.example.schedule": "주요 일정과 마일스톤을 정리해 줘", + "tools.knowledge.example.policy": "보안·개인정보 관련 정책은 뭐야?", + "tools.knowledge.answer": "답변", + "tools.knowledge.similarity": "유사도 {{percent}}%", + "tools.knowledge.queryFailed": "답을 찾지 못했습니다. 문서 색인 상태와 AI 연결을 확인하고 다시 질문하세요.", + "tools.knowledge.queryNeedsDocs": "먼저 문서를 추가하면 질문할 수 있습니다", + "tools.knowledge.chunkProgress": "청크 {{current}}/{{total}} · {{percent}}%", + "tools.knowledge.chunkCount": "청크 {{count}}개", + "tools.knowledge.notIndexed": "색인 안 됨", + "tools.knowledge.reindexItem": "{{name}} 다시 색인", + "tools.knowledge.deleted": "'{{name}}' 문서를 삭제했습니다.", + "tools.knowledge.loadFailed": "문서 목록을 불러오지 못했습니다.", + "tools.knowledge.emptyHint": "TXT·MD·PDF·DOCX 문서를 추가하면 내용에 질문할 수 있습니다.", + "tools.insights.loadFailed": "입력 수집 상태를 불러오지 못했습니다.", + "tools.insights.openSettings": "입력 설정 열기", + "tools.conversation.description": "말하거나 입력해 AI와 대화합니다.", + "tools.conversation.modeRealtime": "실시간 모드", + "tools.conversation.start": "대화 시작", + "tools.conversation.finish": "말하기 끝내고 보내기", + "tools.conversation.stop": "대화 멈추기", + "tools.conversation.cancelResponse": "응답 취소", + "tools.conversation.send": "보내기", + "tools.conversation.clearTitle": "대화 기록을 지울까요?", + "tools.conversation.clearBody": "메시지 {{count}}개가 모두 지워지며 되돌릴 수 없습니다.", + "tools.conversation.clearConfirm": "지우기", + "tools.conversation.clearFailed": "대화 기록을 지우지 못했습니다. 다시 시도하세요." } diff --git a/packages/ui/src/theme-raw.ts b/packages/ui/src/theme-raw.ts index 0eb7654..60028ca 100644 --- a/packages/ui/src/theme-raw.ts +++ b/packages/ui/src/theme-raw.ts @@ -320,10 +320,16 @@ export const FONT_SANS = [ '"Helvetica Neue"', 'Arial', '"Malgun Gothic"', 'sans-serif', ].join(',') -/** 숫자·코드·키 이름(라틴) 전용. 한글이 섞일 수 있는 문자열에는 쓰지 않는다 — 한글 글리프가 없어 자간이 벌어진다. */ +/** + * 숫자·코드·키 이름(라틴) 전용 모노 스택. + * 라틴 모노 폰트에는 한글 글리프가 없다. 바로 뒤에 한글 산세리프를 두지 않으면 Windows 가 한글을 + * 고정폭 한글 폰트(굴림체 계열)로 대체해 "누 름 유 지"처럼 벌어진다(2026-09-28 실측). 그래서 라틴 모노 다음에 + * Pretendard → 시스템 한글 폰트를 두어, 한글이 섞여도 한글 글자만 산세리프로 그려지게 한다. + */ export const FONT_MONO = [ 'ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Menlo', 'Consolas', - '"Liberation Mono"', 'monospace', + '"Liberation Mono"', '"Pretendard Variable"', 'Pretendard', + '"Apple SD Gothic Neo"', '"Noto Sans KR"', '"Malgun Gothic"', 'monospace', ].join(',') // ── CSS Custom Properties ──────────────────────────────────── diff --git a/scripts/i18n-add-keys.mjs b/scripts/i18n-add-keys.mjs new file mode 100644 index 0000000..85a0f48 --- /dev/null +++ b/scripts/i18n-add-keys.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// scripts/i18n-add-keys.mjs +// 번역 키를 ko/en(필요하면 다른 로케일) JSON 에 한 번에 병합한다. 파일 전체를 읽고-합치고-쓰는 동작이 한 프로세스 안에서 +// 끝나므로, 여러 작업자가 같은 로케일 파일을 손으로 Edit 하다 서로 덮어쓰는 일을 막는다. +// +// 사용: node scripts/i18n-add-keys.mjs +// keys.json 형식: { "ko": { "home.title": "오늘" }, "en": { "home.title": "Today" } } +// 이미 있는 키는 값을 바꾼다(--no-overwrite 면 건너뛴다). 새 키는 파일 끝에 붙는다. + +import { readFileSync, writeFileSync, renameSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const localesDir = join(here, '..', 'packages', 'i18n', 'src', 'locales') + +const [input, flag] = process.argv.slice(2) +if (!input) { + process.stderr.write('usage: node scripts/i18n-add-keys.mjs [--no-overwrite]\n') + process.exit(2) +} +const overwrite = flag !== '--no-overwrite' +const patch = JSON.parse(readFileSync(input, 'utf8')) + +let changed = 0 +for (const [locale, entries] of Object.entries(patch)) { + const file = join(localesDir, `${locale}.json`) + const current = JSON.parse(readFileSync(file, 'utf8')) + for (const [key, value] of Object.entries(entries)) { + if (typeof value !== 'string') throw new Error(`${locale}.${key}: value must be a string`) + if (key in current && !overwrite) continue + if (current[key] !== value) { + current[key] = value + changed++ + } + } + const tmp = `${file}.tmp-${process.pid}` + writeFileSync(tmp, `${JSON.stringify(current, null, 2)}\n`, 'utf8') + renameSync(tmp, file) +} +process.stdout.write(`i18n: ${changed} key(s) written\n`)