빅뱅 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 필드 추가
179 lines
5.2 KiB
TypeScript
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)
|
|
}
|