d3ro-voice/apps/desktop/src/renderer/components/LicenseModal.tsx
윤찬 6e52c18e5b feat(desktop+server): Phase 3.2 Premium LLM — Anthropic Claude 프리미엄 파이프라인 + 모델별 쿼터 + SaaS UI
빅뱅 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 필드 추가
2026-04-12 18:28:02 +09:00

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' }}>
&nbsp;
</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>
)
}