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 필드 추가
This commit is contained in:
윤찬 2026-04-12 18:28:02 +09:00
parent d397bcbf57
commit 6e52c18e5b
23 changed files with 1111 additions and 311 deletions

View file

@ -7,10 +7,7 @@ import {
DialogTitle,
DialogContent,
Box,
TextField,
IconButton,
Divider,
CircularProgress,
Table,
TableBody,
TableCell,
@ -21,7 +18,7 @@ import {
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, ScreenPanel, PhysicalButton } from '@d3ro/ui/components/ds'
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'
@ -49,25 +46,11 @@ function tierToLabel(tier: LicenseTier, t: (k: string) => string): string {
}
}
function maskKey(key: string): string {
if (key.length <= 8) return key
return key.slice(0, 4) + '-****-****-' + key.slice(-4)
}
function formatDate(timestamp: number | null): string {
if (!timestamp) return '-'
return new Date(timestamp).toLocaleDateString()
}
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 [keyInput, setKeyInput] = useState('')
const [activating, setActivating] = useState(false)
const [activateMessage, setActivateMessage] = useState<string | null>(null)
const [activateSuccess, setActivateSuccess] = useState(false)
const loadData = useCallback(() => {
window.electronAPI.license.getInfo().then((r) => {
@ -84,9 +67,6 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
useEffect(() => {
if (open) {
loadData()
setKeyInput('')
setActivateMessage(null)
setActivateSuccess(false)
}
}, [open, loadData])
@ -99,37 +79,13 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
return unsub
}, [loadData])
const handleActivate = useCallback(async () => {
if (!keyInput.trim()) return
setActivating(true)
setActivateMessage(null)
try {
const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() })
if (result.success) {
setActivateSuccess(result.data.success)
setActivateMessage(
result.data.success
? t('license.activated')
: t('license.activateError', { message: result.data.message }),
)
if (result.data.success) {
loadData()
setKeyInput('')
}
}
} finally {
setActivating(false)
}
}, [keyInput, t, loadData])
const currentTier = licenseInfo?.tier ?? 'free'
const isFree = currentTier === 'free'
const isPro = currentTier === 'pro'
const handleDeactivate = useCallback(async () => {
await window.electronAPI.license.deactivate()
setActivateMessage(t('license.deactivated'))
setActivateSuccess(false)
loadData()
}, [t, loadData])
const isFree = licenseInfo?.tier === 'free'
const handleUpgrade = useCallback(() => {
alert(t('license.paymentPending'))
}, [t])
return (
<Dialog
@ -164,9 +120,9 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3, overflow: 'auto' }}>
{/* ---- Current Tier ---- */}
<MetalCard>
<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 }}>
@ -178,92 +134,65 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
</Box>
</MetalCard>
{/* ---- Activate / Info ---- */}
<MetalCard>
{isFree ? (
// Free tier: show activation form
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<PhosphorText variant="meta">{t('license.activate')}</PhosphorText>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={t('license.keyPlaceholder')}
size="small"
fullWidth
disabled={activating}
onKeyDown={(e) => {
if (e.key === 'Enter') handleActivate()
}}
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.input,
},
}}
/>
<PhysicalButton
onClick={handleActivate}
disabled={activating || !keyInput.trim()}
sx={{ minWidth: 100 }}
>
{activating ? (
<CircularProgress size={16} sx={{ color: d3roPalette.accent.amber }} />
) : (
t('license.activate')
)}
{/* ---- 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>
</Box>
{activateMessage && (
<PhosphorText
variant="small"
sx={{ color: activateSuccess ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{activateMessage}
<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>
) : (
// Pro/Pro+: show license info
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<PhosphorText variant="meta">{t('license.keyLabel')}</PhosphorText>
<ScreenPanel>
<Box sx={{ px: 2, py: 1.5 }}>
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
{licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'}
</PhosphorText>
</Box>
</ScreenPanel>
<Box sx={{ display: 'flex', gap: 3 }}>
<Box>
<PhosphorText variant="label">{t('license.activatedAt')}</PhosphorText>
<PhosphorText variant="compact">
{formatDate(licenseInfo?.activatedAt ?? null)}
</PhosphorText>
</Box>
<Box>
<PhosphorText variant="label">{t('license.machineId')}</PhosphorText>
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
{licenseInfo?.machineId?.slice(0, 12) ?? '-'}...
</PhosphorText>
</Box>
</Box>
<PhysicalButton
onClick={handleDeactivate}
sx={{ alignSelf: 'flex-start', mt: 1, color: d3roPalette.tag.red }}
>
{t('license.deactivate')}
</PhysicalButton>
</Box>
)}
)}
</Box>
</MetalCard>
{/* ---- Daily Usage ---- */}
{usageQuotas.length > 0 && (
<MetalCard>
<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) => (
@ -309,7 +238,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
{/* ---- Tier Comparison ---- */}
{tierComparison.length > 0 && (
<MetalCard>
<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 } }}>
@ -333,7 +262,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
{tierComparison.map((row) => (
<TableRow key={row.feature}>
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
{row.featureLabel}
{t(row.featureLabel as Parameters<typeof t>[0])}
</TableCell>
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>