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:
parent
d397bcbf57
commit
6e52c18e5b
23 changed files with 1111 additions and 311 deletions
|
|
@ -693,43 +693,78 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.ollamaServer')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.ollamaUrl')}
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.ollamaHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.llmModel')}
|
||||
{t('settings.llmBackend')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.llmModel')}</InputLabel>
|
||||
<InputLabel>{t('settings.llmBackend')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.llmModel')}
|
||||
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
|
||||
label={t('settings.llmBackend')}
|
||||
value={config.llmBackend ?? 'local'}
|
||||
onChange={(e) => {
|
||||
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
|
||||
const next = e.target.value
|
||||
updateConfig('llmBackend', next)
|
||||
if (next === 'premium') {
|
||||
// Premium 선택 시 항상 라이선스 모달 — 현재 티어/쿼터/업그레이드 안내
|
||||
window.dispatchEvent(new Event('d3ro:open-license-modal'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{llmModels.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
{model.name} ({model.parameterSize})
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="local">{t('settings.backend.local')}</MenuItem>
|
||||
<MenuItem value="premium">{t('settings.backend.premium')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{config.llmBackend === 'premium'
|
||||
? t('settings.backend.premiumHint')
|
||||
: t('settings.backend.localHint')}
|
||||
</Typography>
|
||||
|
||||
{config.llmBackend !== 'premium' && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.ollamaServer')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.ollamaUrl')}
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.ollamaHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.llmModel')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.llmModel')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.llmModel')}
|
||||
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
|
||||
onChange={(e) => {
|
||||
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
|
||||
}}
|
||||
>
|
||||
{llmModels.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
{model.name} ({model.parameterSize})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue