빅뱅 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 필드 추가
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
// src/renderer/components/LicenseModal.tsx
|
|
// Full-screen license management modal with instrument aesthetic
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
Box,
|
|
IconButton,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
} from '@mui/material'
|
|
import CloseIcon from '@mui/icons-material/Close'
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
|
import CancelIcon from '@mui/icons-material/Cancel'
|
|
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
|
|
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@d3ro/core/types'
|
|
|
|
interface LicenseModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
|
|
|
function tierToLedColor(tier: LicenseTier): LedColor {
|
|
switch (tier) {
|
|
case 'free': return 'amber'
|
|
case 'pro': return 'green'
|
|
case 'pro_plus': return 'green'
|
|
}
|
|
}
|
|
|
|
function tierToLabel(tier: LicenseTier, t: (k: string) => string): string {
|
|
switch (tier) {
|
|
case 'free': return t('license.free')
|
|
case 'pro': return t('license.pro')
|
|
case 'pro_plus': return t('license.proPlus')
|
|
}
|
|
}
|
|
|
|
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
|
|
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
|
|
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
|
|
|
const loadData = useCallback(() => {
|
|
window.electronAPI.license.getInfo().then((r) => {
|
|
if (r.success) setLicenseInfo(r.data)
|
|
})
|
|
window.electronAPI.license.getTierComparison().then((r) => {
|
|
if (r.success) setTierComparison(r.data)
|
|
})
|
|
window.electronAPI.license.getAllUsage().then((r) => {
|
|
if (r.success) setUsageQuotas(r.data)
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
loadData()
|
|
}
|
|
}, [open, loadData])
|
|
|
|
// Subscribe to tier changes
|
|
useEffect(() => {
|
|
const unsub = window.electronAPI.license.onTierChanged((info) => {
|
|
setLicenseInfo(info)
|
|
loadData()
|
|
})
|
|
return unsub
|
|
}, [loadData])
|
|
|
|
const currentTier = licenseInfo?.tier ?? 'free'
|
|
const isFree = currentTier === 'free'
|
|
const isPro = currentTier === 'pro'
|
|
|
|
const handleUpgrade = useCallback(() => {
|
|
alert(t('license.paymentPending'))
|
|
}, [t])
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
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',
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
py: 1.5,
|
|
px: 3,
|
|
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
|
}}
|
|
>
|
|
<PhosphorText variant="meta">{t('license.title')}</PhosphorText>
|
|
<IconButton size="small" onClick={onClose} sx={{ color: d3roPalette.text.inactive }}>
|
|
<CloseIcon sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
|
|
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3, overflow: 'auto' }}>
|
|
{/* ---- Current Tier ---- */}
|
|
<MetalCard sx={{ overflow: 'visible' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
|
|
<Box sx={{ flex: 1 }}>
|
|
<PhosphorText variant="meta">{t('license.currentTier')}</PhosphorText>
|
|
<PhosphorText variant="value">
|
|
{licenseInfo ? tierToLabel(licenseInfo.tier, t) : '...'}
|
|
</PhosphorText>
|
|
</Box>
|
|
</Box>
|
|
</MetalCard>
|
|
|
|
{/* ---- Subscription Management ---- */}
|
|
<MetalCard sx={{ overflow: 'visible' }}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<PhosphorText variant="meta">{t('license.subscribe')}</PhosphorText>
|
|
|
|
{isFree && (
|
|
<>
|
|
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color="green" size={8} />
|
|
<PhosphorText variant="compact">
|
|
{t('license.upgrade')} — {t('license.proPlan')}
|
|
</PhosphorText>
|
|
</Box>
|
|
</PhysicalButton>
|
|
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color="green" size={8} />
|
|
<PhosphorText variant="compact">
|
|
{t('license.upgrade')} — {t('license.proPlusPlan')}
|
|
</PhosphorText>
|
|
</Box>
|
|
</PhysicalButton>
|
|
</>
|
|
)}
|
|
|
|
{isPro && (
|
|
<>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Led color="green" size={8} pulse />
|
|
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.green }}>
|
|
{t('license.currentPlan')} — {t('license.proPlan')}
|
|
</PhosphorText>
|
|
</Box>
|
|
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color="green" size={8} />
|
|
<PhosphorText variant="compact">
|
|
{t('license.upgrade')} — {t('license.proPlusPlan')}
|
|
</PhosphorText>
|
|
</Box>
|
|
</PhysicalButton>
|
|
</>
|
|
)}
|
|
|
|
{currentTier === 'pro_plus' && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Led color="green" size={8} pulse />
|
|
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.purple }}>
|
|
{t('license.currentPlan')} — {t('license.proPlusPlan')}
|
|
</PhosphorText>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</MetalCard>
|
|
|
|
{/* ---- Daily Usage ---- */}
|
|
{usageQuotas.length > 0 && (
|
|
<MetalCard sx={{ overflow: 'visible' }}>
|
|
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{usageQuotas.map((q) => (
|
|
<Box key={q.feature} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<PhosphorText variant="small" sx={{ flex: 1 }}>
|
|
{t(`license.feature.${q.feature}` as Parameters<typeof t>[0])}
|
|
</PhosphorText>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Box
|
|
sx={{
|
|
height: 4,
|
|
borderRadius: d3roRadius.pill,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
height: '100%',
|
|
borderRadius: d3roRadius.pill,
|
|
bgcolor:
|
|
q.limit < 0
|
|
? d3roPalette.tag.green
|
|
: q.used >= q.limit
|
|
? d3roPalette.tag.red
|
|
: d3roPalette.accent.amber,
|
|
width: q.limit < 0 ? '100%' : `${Math.min(100, (q.used / q.limit) * 100)}%`,
|
|
transition: 'width 0.3s ease',
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
<PhosphorText variant="dim" sx={{ minWidth: 60, textAlign: 'right' }}>
|
|
{q.limit < 0
|
|
? t('license.quotaUnlimited')
|
|
: t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })}
|
|
</PhosphorText>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</MetalCard>
|
|
)}
|
|
|
|
{/* ---- Tier Comparison ---- */}
|
|
{tierComparison.length > 0 && (
|
|
<MetalCard sx={{ overflow: 'visible' }}>
|
|
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
|
|
<TableContainer>
|
|
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing, textTransform: 'uppercase' }}>
|
|
|
|
</TableCell>
|
|
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing }}>
|
|
{t('license.free')}
|
|
</TableCell>
|
|
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.green, letterSpacing: d3roTypo.label.spacing }}>
|
|
{t('license.pro')}
|
|
</TableCell>
|
|
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.purple, letterSpacing: d3roTypo.label.spacing }}>
|
|
{t('license.proPlus')}
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{tierComparison.map((row) => (
|
|
<TableRow key={row.feature}>
|
|
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
|
|
{t(row.featureLabel as Parameters<typeof t>[0])}
|
|
</TableCell>
|
|
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
|
|
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
|
|
<TableCell align="center">{renderTierCell(row.proPlus)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</MetalCard>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function renderTierCell(value: boolean | string): React.ReactElement {
|
|
if (typeof value === 'boolean') {
|
|
return value ? (
|
|
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.tag.green }} />
|
|
) : (
|
|
<CancelIcon sx={{ fontSize: 16, color: d3roPalette.text.disabled }} />
|
|
)
|
|
}
|
|
return (
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.compact.size }}>
|
|
{value}
|
|
</PhosphorText>
|
|
)
|
|
}
|