빅뱅 8/8 마지막 성공 기준 달성. Supabase Edge Function(llm-proxy)을 통해 Anthropic Claude를 호출하는 PremiumLLMService 신규 구현. 사용자가 Settings에서 Local/Premium 백엔드를 선택하면 VoiceModeService가 자동 분기하고, Premium 실패 시 Local로 silent fallback + 상단 중앙 배너 알림. 실측: Claude Haiku refine 1.6~3.2초 (이전 qwen3 42.9초 → 13~27배 빠름). 주요 변경: - PremiumLLMService 신규 (싱글톤+EventEmitter, processText/chatStream, Supabase functions.invoke 기반, _ensureAuth 가드) - llm-prompts.ts: SYSTEM_PROMPTS를 Local/Premium 공유 모듈로 추출 (resolveSystemPrompt 헬퍼) - VoiceModeService: _getLLMProcessor → _runProcessorWithFallback 라우터 + premium-llm-fallback 이벤트 - CloudSyncService: getAccessToken(async), getAnonKey, invokeFunction(auth 헤더 자동 처리, 에러 body 파싱) - IPC: LLM.PREMIUM_* 채널 6개 + preload API + llm-handlers 이벤트 전달 (safeSendToRenderer 헬퍼) - AppConfig.llmBackend: 'local' | 'premium' (기본 'local') - Settings UI: Backend 드롭다운 + Premium 선택 시 Ollama UI 숨김 + 라이선스 모달 자동 오픈 - AppLayout: 상단 중앙 Snackbar fallback 배너 (8초, warning filled) - LicenseModal: 라이선스 키 입력 제거 → SaaS 구독 관리 UI 전환 (Free/Pro/Pro+ 업그레이드 버튼, Payple 준비 중 스텁) - 등급 비교 표: featureLabel i18n 번역 수정 서버 (Supabase Edge Functions): - quota.ts: 모델별 쿼터 구조 (llm_haiku/sonnet/opus × free/pro/pro_plus), 주간/일간 기간 분리, modelToQuotaKey 매핑, consumeQuota baseLimit 파라미터화 - llm-proxy: 모델별 쿼터 체크 + 소비 (checkQuota → consumeQuota 원자적), verify_jwt=false (2026 sb_publishable_ 키 호환) - config.toml: llm-proxy verify_jwt = false - migration 20260412000001: tier team→pro_plus 통일, subscriptions.overage_credits 컬럼, consume_quota RPC (원자적 base→overage fallback) Tier/쿼터: - free: Haiku 250/주간, Sonnet/Opus 불가 - pro ₩9,900: Haiku 1500/일, Sonnet 300/일, Opus 50/일 - pro_plus ₩29,900: Haiku 무제한, Sonnet 1500/일, Opus 300/일 - api-client SubscriptionTier: team→pro_plus, overage_credits 필드 추가
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
// src/renderer/components/AppLayout.tsx
|
|
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Alert, Box, Snackbar, Typography, Tooltip } from '@mui/material'
|
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
|
import HistoryIcon from '@mui/icons-material/History'
|
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
|
import ExtensionIcon from '@mui/icons-material/Extension'
|
|
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
|
|
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
|
import GroupsIcon from '@mui/icons-material/Groups'
|
|
import SettingsIcon from '@mui/icons-material/Settings'
|
|
import { Led } from '@d3ro/ui/components/ds'
|
|
import { DashboardPage } from '../pages/DashboardPage'
|
|
import { HistoryPage } from '../pages/HistoryPage'
|
|
import { DictionaryPage } from '../pages/DictionaryPage'
|
|
import { CommandsPage } from '../pages/CommandsPage'
|
|
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
|
|
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
|
|
import { MeetingModePage } from '../pages/MeetingModePage'
|
|
import { SettingsModal } from './SettingsModal'
|
|
import { LicenseModal } from './LicenseModal'
|
|
import { OnboardingModal } from './OnboardingModal'
|
|
import { StatusBar } from './StatusBar'
|
|
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import type { TranslationKey } from '@d3ro/i18n'
|
|
import type { LicenseTier } from '@d3ro/core/types'
|
|
|
|
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting'
|
|
|
|
interface NavItem {
|
|
route: Route
|
|
labelKey: TranslationKey
|
|
abbr: string
|
|
icon: React.ReactElement
|
|
}
|
|
|
|
const NAV_ITEMS: NavItem[] = [
|
|
{ route: 'dashboard', labelKey: 'nav.dashboard', abbr: 'DASH', icon: <DashboardIcon sx={{ fontSize: 20 }} /> },
|
|
{ route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
|
|
{ route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
|
|
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
|
|
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
|
|
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
|
|
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
|
|
]
|
|
|
|
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
|
|
switch (tier) {
|
|
case 'free': return 'amber'
|
|
case 'pro': return 'green'
|
|
case 'pro_plus': return 'green'
|
|
}
|
|
}
|
|
|
|
export function AppLayout(): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
|
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
|
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
|
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
|
|
const [fallbackMsg, setFallbackMsg] = useState<string | null>(null)
|
|
|
|
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
|
|
useEffect(() => {
|
|
window.electronAPI.config.getAll().then((r) => {
|
|
if (r.success && !r.data.onboardingCompleted) {
|
|
setOnboardingOpen(true)
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
// License: load tier + subscribe to changes + listen for open-modal events
|
|
useEffect(() => {
|
|
window.electronAPI.license.getInfo().then((r) => {
|
|
if (r.success) setCurrentTier(r.data.tier)
|
|
})
|
|
|
|
const unsubTier = window.electronAPI.license.onTierChanged((info) => {
|
|
setCurrentTier(info.tier)
|
|
})
|
|
|
|
const unsubUpgrade = window.electronAPI.license.onUpgradePrompt(() => {
|
|
setLicenseModalOpen(true)
|
|
})
|
|
|
|
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
|
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
|
|
|
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
|
|
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
|
|
setFallbackMsg(e.reason)
|
|
})
|
|
const unsubUpgradeReq = window.electronAPI.llm.premium.onUpgradeRequired(() => {
|
|
setLicenseModalOpen(true)
|
|
})
|
|
|
|
return () => {
|
|
unsubTier()
|
|
unsubUpgrade()
|
|
unsubFallback()
|
|
unsubUpgradeReq()
|
|
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
|
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
|
|
<Box
|
|
sx={{
|
|
width: 72,
|
|
flexShrink: 0,
|
|
bgcolor: d3roPalette.bg.sidebar,
|
|
borderRight: `1px solid ${d3roPalette.border.subtle}`,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
py: 2,
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{/* 로고 LED — reflects license tier */}
|
|
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
|
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={10} />
|
|
<Typography
|
|
sx={{
|
|
fontSize: d3roTypo.micro.size,
|
|
fontFamily: d3roFontMono,
|
|
letterSpacing: d3roTypo.micro.spacing,
|
|
color: d3roPalette.text.dimLabel,
|
|
fontWeight: d3roTypo.micro.weight,
|
|
}}
|
|
>
|
|
D3RO
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* 네비게이션 버튼 */}
|
|
{NAV_ITEMS.map((item) => {
|
|
const isActive = currentRoute === item.route
|
|
return (
|
|
<Tooltip key={item.route} title={t(item.labelKey)} placement="right" arrow>
|
|
<Box
|
|
onClick={() => setCurrentRoute(item.route)}
|
|
sx={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: d3roRadius.button,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 0.5,
|
|
cursor: 'pointer',
|
|
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
|
|
boxShadow: isActive
|
|
? d3roShadow.buttonPressed
|
|
: d3roShadow.buttonRaised,
|
|
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
|
transition: 'all 0.05s linear',
|
|
transform: isActive ? 'translateY(1px)' : 'none',
|
|
'&:active': {
|
|
transform: 'translateY(2px)',
|
|
boxShadow: d3roShadow.buttonPressed,
|
|
},
|
|
'&:hover': {
|
|
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
|
|
},
|
|
}}
|
|
>
|
|
{item.icon}
|
|
<Typography
|
|
sx={{
|
|
fontSize: d3roTypo.nano.size,
|
|
fontFamily: d3roFontMono,
|
|
fontWeight: d3roTypo.nano.weight,
|
|
letterSpacing: d3roTypo.nano.spacing,
|
|
}}
|
|
>
|
|
{item.abbr}
|
|
</Typography>
|
|
</Box>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
|
|
{/* 스페이서 */}
|
|
<Box sx={{ flex: 1 }} />
|
|
|
|
{/* Settings */}
|
|
<Tooltip title={t('nav.settings')} placement="right" arrow>
|
|
<Box
|
|
onClick={() => setSettingsOpen(true)}
|
|
sx={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: d3roRadius.button,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
color: d3roPalette.text.inactive,
|
|
boxShadow: d3roShadow.buttonRaised,
|
|
transition: 'all 0.05s linear',
|
|
'&:active': {
|
|
transform: 'translateY(2px)',
|
|
boxShadow: d3roShadow.buttonPressed,
|
|
},
|
|
'&:hover': { color: d3roPalette.text.hover },
|
|
}}
|
|
>
|
|
<SettingsIcon sx={{ fontSize: 20 }} />
|
|
</Box>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* ── 콘텐츠 영역 ────────────────────────── */}
|
|
<Box
|
|
component="main"
|
|
sx={{
|
|
flexGrow: 1,
|
|
overflow: 'auto',
|
|
bgcolor: d3roPalette.bg.app,
|
|
// 미묘한 방사형 비네팅 (시안 A 배경)
|
|
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
|
|
}}
|
|
>
|
|
{currentRoute === 'dashboard' && <DashboardPage />}
|
|
{currentRoute === 'history' && <HistoryPage />}
|
|
{currentRoute === 'dictionary' && <DictionaryPage />}
|
|
{currentRoute === 'commands' && <CommandsPage />}
|
|
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
|
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
|
{currentRoute === 'meeting' && <MeetingModePage />}
|
|
</Box>
|
|
</Box>
|
|
<StatusBar />
|
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
|
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
|
|
|
{/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */}
|
|
<Snackbar
|
|
open={fallbackMsg !== null}
|
|
autoHideDuration={8000}
|
|
onClose={() => setFallbackMsg(null)}
|
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
>
|
|
<Alert severity="warning" variant="filled" onClose={() => setFallbackMsg(null)} sx={{ width: '100%' }}>
|
|
{fallbackMsg}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
)
|
|
}
|