d3ro-voice/apps/desktop/src/main/services/ConfigService.ts
윤찬 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

179 lines
5.2 KiB
TypeScript

// src/main/services/ConfigService.ts
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent } from '@d3ro/core/types'
import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
// ============================================================
// SaaS 빌드 타임 주입 (electron.vite.config.ts의 define으로 박힘)
// ============================================================
// D3RO_SUPABASE_URL / D3RO_SUPABASE_ANON_KEY가 비어있지 않으면
// 데스크톱 사용자가 Settings에서 직접 입력할 필요 없이 SaaS 인스턴스에 자동 연결.
// 빈 문자열이면 기존 동작(사용자 입력) fallback.
const BUILD_TIME_SUPABASE_URL: string = process.env.D3RO_SUPABASE_URL ?? ''
const BUILD_TIME_SUPABASE_ANON_KEY: string = process.env.D3RO_SUPABASE_ANON_KEY ?? ''
export function isSupabaseBuildTimeConfigured(): boolean {
return BUILD_TIME_SUPABASE_URL.length > 0 && BUILD_TIME_SUPABASE_ANON_KEY.length > 0
}
export function getBuildTimeSupabaseUrl(): string {
return BUILD_TIME_SUPABASE_URL
}
export function getBuildTimeSupabaseAnonKey(): string {
return BUILD_TIME_SUPABASE_ANON_KEY
}
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T extends Record<string, unknown>> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
store: T
}
const CONFIG_DEFAULTS: AppConfig = {
theme: 'auto',
language: 'ko',
closeToTray: true,
autoLaunch: false,
soundEnabled: true,
selectedDeviceId: null,
sttModelId: 'base',
sttLanguage: 'auto',
ttsVoiceId: null,
ttsSpeed: 1.0,
ollamaServerUrl: 'http://localhost:11434',
llmModelId: null,
// Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는
// 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요.
llmBackend: 'local' as const,
defaultLLMAction: 'refine',
dictationShortcut: {
keyCode: 0xa5, // Right Alt
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt'
},
handsFreeShortcut: {
keyCode: 0xa5,
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt (double)'
},
commandShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl + Right Alt'
},
captionShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: true,
meta: false,
displayLabel: 'Ctrl + Shift + Right Alt'
},
hotkeyEnabled: true,
insertMethod: 'clipboard',
autoInsert: true,
maxHistoryEntries: 1000,
dictationEnabled: true,
agentModeEnabled: false,
handsFreeEnabled: false,
screenContextEnabled: false,
hfToken: '',
diarizationEnabled: false,
supabaseUrl: '',
supabaseAnonKey: '',
cloudSyncLastAt: null,
onboardingCompleted: false,
}
let store: ElectronStore<AppConfig> | null = null
const emitter = new EventEmitter()
export async function initConfigService(): Promise<void> {
const { default: Store } = await import('electron-store')
store = new Store<AppConfig>({
name: 'd3ro-voice-config',
defaults: CONFIG_DEFAULTS
})
logger.info('ConfigService initialized')
}
export function getConfigService(): ElectronStore<AppConfig> | null {
return store
}
export function configGet<K extends keyof AppConfig>(key: K): AppConfig[K] {
// SaaS 빌드 타임 주입은 사용자 설정보다 우선.
if (key === 'supabaseUrl' && BUILD_TIME_SUPABASE_URL.length > 0) {
return BUILD_TIME_SUPABASE_URL as AppConfig[K]
}
if (key === 'supabaseAnonKey' && BUILD_TIME_SUPABASE_ANON_KEY.length > 0) {
return BUILD_TIME_SUPABASE_ANON_KEY as AppConfig[K]
}
if (!store) {
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
return CONFIG_DEFAULTS[key]
}
return store.get(key)
}
export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
// SaaS 빌드 타임 주입된 값은 변경 차단 (사용자가 잘못된 값으로 덮어쓰는 것 방지).
if (
(key === 'supabaseUrl' || key === 'supabaseAnonKey') &&
isSupabaseBuildTimeConfigured()
) {
logger.warn(`Refusing to override build-time SaaS config: "${key}"`)
return
}
if (!store) {
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
return
}
const previousValue = store.get(key)
store.set(key, value)
const event: ConfigChangedEvent = {
key,
value,
previousValue
}
emitter.emit('config-changed', event)
logger.debug(`Config changed: ${key}`)
}
export function configGetAll(): AppConfig {
if (!store) return { ...CONFIG_DEFAULTS }
return store.store
}
export function configReset(key?: keyof AppConfig): void {
if (!store) return
if (key) {
store.set(key, CONFIG_DEFAULTS[key])
} else {
store.store = { ...CONFIG_DEFAULTS }
}
}
export function onConfigChanged(callback: (event: ConfigChangedEvent) => void): () => void {
emitter.on('config-changed', callback)
return () => emitter.off('config-changed', callback)
}