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
|
|
@ -74,6 +74,11 @@ interface VoiceModeEvents {
|
|||
}) => void
|
||||
'audio-level': (payload: { level: number }) => void
|
||||
error: (payload: { error: D3ROError; session: VoiceSession | null }) => void
|
||||
/**
|
||||
* Phase 3.2: Premium LLM 호출이 실패해 Local로 자동 fallback된 경우 emit.
|
||||
* renderer에서 이 이벤트를 받아 Snackbar 경고 배너를 띄운다.
|
||||
*/
|
||||
'premium-llm-fallback': (payload: { reason: string }) => void
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -547,9 +552,14 @@ class VoiceModeService extends EventEmitter {
|
|||
// VoiceCommandService 미초기화 시 무시
|
||||
}
|
||||
|
||||
// LLM 후처리: none이면 스킵, 그 외에는 LLM 처리
|
||||
// LLM 후처리: none이면 스킵, local backend인데 Ollama 미가용 시도 스킵.
|
||||
// premium backend는 내부에서 local fallback을 시도하므로 스킵 안 함.
|
||||
const llmAction = overrideAction ?? configGet('defaultLLMAction')
|
||||
if (llmAction === 'none' || !getLocalLLMService().isAvailable()) {
|
||||
const backend = configGet('llmBackend')
|
||||
const skipLLM =
|
||||
llmAction === 'none' ||
|
||||
(backend === 'local' && !getLocalLLMService().isAvailable())
|
||||
if (skipLLM) {
|
||||
this._completeSession(effectiveText)
|
||||
} else {
|
||||
await this._processWithLLM(effectiveText, overrideInstructionId)
|
||||
|
|
@ -567,11 +577,69 @@ class VoiceModeService extends EventEmitter {
|
|||
|
||||
// ── LLM 후처리 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Phase 3.2: llmBackend config + PremiumLLMService 가용성으로
|
||||
* local/premium 분기. premium 선택 시 처리 도중 실패하면 local로
|
||||
* silent fallback + 'premium-llm-fallback' 이벤트 emit.
|
||||
*
|
||||
* 반환: 실제 사용할 processText 함수 + 사용된 백엔드 이름.
|
||||
*/
|
||||
private async _getLLMProcessor(): Promise<{
|
||||
service: { processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string> }
|
||||
backend: 'local' | 'premium'
|
||||
}> {
|
||||
const backend = configGet('llmBackend')
|
||||
if (backend === 'premium') {
|
||||
try {
|
||||
const { getPremiumLLMService } = await import('./PremiumLLMService')
|
||||
const premium = getPremiumLLMService()
|
||||
if (premium.isAvailable()) {
|
||||
return { service: premium, backend: 'premium' }
|
||||
}
|
||||
this._emitPremiumFallback('Premium 사용 불가 — 로그인 또는 네트워크 확인')
|
||||
} catch (err) {
|
||||
this._emitPremiumFallback(
|
||||
`Premium 초기화 실패: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { service: getLocalLLMService(), backend: 'local' }
|
||||
}
|
||||
|
||||
private _emitPremiumFallback(reason: string): void {
|
||||
logger.warn(`Premium LLM fallback → local: ${reason}`)
|
||||
this.emit('premium-llm-fallback', { reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.2: backend 선택 + Premium 실패 시 Local 자동 fallback을 캡슐화한
|
||||
* processText 호출. 성공 시 결과 텍스트를 반환하고 사용된 backend 로깅.
|
||||
*/
|
||||
private async _runProcessorWithFallback(
|
||||
text: string,
|
||||
action: LLMAction,
|
||||
targetLanguage?: string,
|
||||
customPrompt?: string,
|
||||
): Promise<string> {
|
||||
const processor = await this._getLLMProcessor()
|
||||
try {
|
||||
return await processor.service.processText(text, action, targetLanguage, customPrompt)
|
||||
} catch (err) {
|
||||
if (processor.backend === 'premium') {
|
||||
this._emitPremiumFallback(
|
||||
`Premium 호출 실패: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
// Local로 재시도
|
||||
return getLocalLLMService().processText(text, action, targetLanguage, customPrompt)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise<void> {
|
||||
if (this._isInTerminalState()) return
|
||||
|
||||
try {
|
||||
const llm = getLocalLLMService()
|
||||
const action = configGet('defaultLLMAction')
|
||||
|
||||
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
|
||||
|
|
@ -620,10 +688,10 @@ class VoiceModeService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
processedText = await llm.processText(customPrompt, 'custom')
|
||||
processedText = await this._runProcessorWithFallback(customPrompt, 'custom')
|
||||
} else {
|
||||
logger.info(`Processing with LLM (action: ${action})`)
|
||||
processedText = await llm.processText(contextPrefix + transcribedText, action)
|
||||
processedText = await this._runProcessorWithFallback(contextPrefix + transcribedText, action)
|
||||
}
|
||||
|
||||
if (this._isInTerminalState()) return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue