diff --git a/apps/desktop/src/main/ipc/llm-handlers.ts b/apps/desktop/src/main/ipc/llm-handlers.ts index 5ac8036..cb57572 100644 --- a/apps/desktop/src/main/ipc/llm-handlers.ts +++ b/apps/desktop/src/main/ipc/llm-handlers.ts @@ -4,18 +4,38 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' import { getLocalLLMService } from '../services/LocalLLMService' +import { getPremiumLLMService } from '../services/PremiumLLMService' +import { getVoiceModeService } from '../services/VoiceModeService' import { configGet, configSet } from '../services/ConfigService' import { getMainWindow } from '../windows/WindowManager' import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@d3ro/core/types' +function safeSendToRenderer(channel: string, data: unknown): void { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send(channel, data) + } +} + export function registerLLMHandlers(): void { // LLM 가용성 변경 시 렌더러에 상태 전파 const llm = getLocalLLMService() llm.on('availability-changed', () => { - const win = getMainWindow() - if (win && !win.isDestroyed()) { - win.webContents.send(IPC_CHANNELS.LLM.STATUS_CHANGED, { status: llm.getStatus() }) - } + safeSendToRenderer(IPC_CHANNELS.LLM.STATUS_CHANGED, { status: llm.getStatus() }) + }) + + // Phase 3.2: VoiceModeService의 premium-llm-fallback 이벤트를 렌더러로 전달 + getVoiceModeService().on('premium-llm-fallback', (payload: { reason: string }) => { + safeSendToRenderer(IPC_CHANNELS.LLM.PREMIUM_FALLBACK, payload) + }) + + // Phase 3.2: PremiumLLMService 이벤트 → 렌더러 + const premium = getPremiumLLMService() + premium.on('quota-warning', (payload) => { + safeSendToRenderer(IPC_CHANNELS.LLM.PREMIUM_QUOTA_WARNING, payload) + }) + premium.on('upgrade-required', (payload) => { + safeSendToRenderer(IPC_CHANNELS.LLM.PREMIUM_UPGRADE_REQUIRED, payload) }) ipcMain.handle(IPC_CHANNELS.LLM.GET_STATUS, async () => { return ipcSuccess(getLocalLLMService().getStatus()) @@ -74,4 +94,19 @@ export function registerLLMHandlers(): void { configSet('ollamaServerUrl', params.url) return ipcSuccess(undefined) }) + + // Phase 3.2: Premium LLM 상태/쿼터 조회 + ipcMain.handle(IPC_CHANNELS.LLM.PREMIUM_GET_STATUS, async () => { + const premium = getPremiumLLMService() + return ipcSuccess({ + available: premium.isAvailable(), + backend: configGet('llmBackend') + }) + }) + + ipcMain.handle(IPC_CHANNELS.LLM.PREMIUM_GET_QUOTA, async () => { + const premium = getPremiumLLMService() + const snapshot = premium.getLastQuota() + return ipcSuccess(snapshot) + }) } diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 64720e4..bfa692d 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -355,6 +355,73 @@ class CloudSyncService extends EventEmitter { return this._session?.user ?? null } + /** + * Phase 3.2: Premium LLM proxy 호출 시 Supabase Edge Function Authorization + * 헤더에 사용할 JWT access token 반환. null이면 비로그인 상태. + * + * 중요: 캐시된 _session이 아닌 Supabase 클라이언트에서 최신 세션을 직접 가져옴. + * _session은 로그인 시점에만 설정되지만, Supabase 클라이언트 내부에서 자동 + * refresh된 토큰은 _session에 반영되지 않아 stale JWT가 될 수 있음. + */ + async getAccessToken(): Promise { + if (!this._client) return null + const { data } = await this._client.auth.getSession() + return data.session?.access_token ?? null + } + + /** + * Phase 3.2: Edge Function 호출을 위한 Supabase 프로젝트 URL. + * 없으면 SaaS 미설정 상태. + */ + getSupabaseUrl(): string | null { + const url = configGet('supabaseUrl') as string | undefined + return url ?? null + } + + /** + * Phase 3.2: Edge Function 호출 시 apikey 헤더에 필요한 anon key. + */ + getAnonKey(): string | null { + const key = configGet('supabaseAnonKey') as string | undefined + return key ?? null + } + + /** + * Phase 3.2: Supabase Edge Function 호출 — 클라이언트가 auth 헤더를 올바르게 처리. + * raw fetch 대신 이걸 사용해야 gateway 레벨 401 방지. + */ + async invokeFunction(name: string, body: Record): Promise<{ data: unknown; error: { message: string } | null }> { + if (!this._client) { + return { data: null, error: { message: 'Supabase client not initialized' } } + } + + // 최신 세션 확보 (auto-refresh 보장) + const { data: sessionData } = await this._client.auth.getSession() + const token = sessionData.session?.access_token + if (!token) { + return { data: null, error: { message: 'No active session — 로그인 필요' } } + } + + const { data, error } = await this._client.functions.invoke(name, { + body, + headers: { Authorization: `Bearer ${token}` }, + }) + + if (error) { + let detail = error.message ?? String(error) + try { + if ('context' in error && error.context instanceof Response) { + const respBody = await (error.context as Response).json() + detail = JSON.stringify(respBody) + } + } catch { + // body 파싱 실패 시 기본 메시지 사용 + } + return { data: null, error: { message: detail } } + } + return { data, error: null } + } + getState(): CloudSyncState { return { authenticated: this.isAuthenticated(), diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 51fc521..3ca0cac 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -49,6 +49,9 @@ const CONFIG_DEFAULTS: AppConfig = { 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 diff --git a/apps/desktop/src/main/services/LicenseService.ts b/apps/desktop/src/main/services/LicenseService.ts index 5136145..cf873db 100644 --- a/apps/desktop/src/main/services/LicenseService.ts +++ b/apps/desktop/src/main/services/LicenseService.ts @@ -31,16 +31,24 @@ function generateMachineId(): string { // ── 티어별 쿼터 한도 ────────────────────────────────────── // -1 = 무제한, 값이 있으면 일일 한도. // 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용. +// Phase 3.2: 엔트리 흡수 전략으로 free 쿼터 대폭 상향 (5 → 250). +// 오버리지 크레딧은 서버 subscriptions.overage_credits 컬럼에서 별도 관리. +// Phase 3.2: 모델별 쿼터. 서버(quota.ts)와 동기화. +// 클라이언트에서는 PREMIUM_LLM feature로 묶어서 canUse() 체크하고, +// 실제 모델별 세분화는 서버 llm-proxy가 담당. +// 여기의 값은 Settings UI 표시용 + upgrade 유도 시점 판단용. const QUOTA_LIMITS: Record>> = { free: { - // PREMIUM_LLM은 로그인한 free 사용자에게 하루 5회 맛보기 - [Feature.PREMIUM_LLM]: 5, + // Haiku만, 250/주간. 클라이언트에서는 대략적 일환산(~36/일)으로 표시. + [Feature.PREMIUM_LLM]: 250, }, pro: { - // pro는 Premium LLM 무제한이지만 fair-use cap - [Feature.PREMIUM_LLM]: 500, + // 모델별: Haiku 1500 + Sonnet 300 + Opus 50 = 합산 표시 + [Feature.PREMIUM_LLM]: 1850, + }, + pro_plus: { + // Haiku 무제한 + Sonnet 1500 + Opus 300 }, - pro_plus: {}, } // ── 기능별 최소 필요 티어 ────────────────────────────────── diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index a80cdc0..66263dc 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -10,6 +10,7 @@ import { getLogger } from './LoggerService' import { configGet } from './ConfigService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types' +import { resolveSystemPrompt } from './llm-prompts' const logger = getLogger('LocalLLMService') @@ -70,7 +71,6 @@ interface LocalLLMEvents { // 시스템 프롬프트 (설계서 Phase 4 참조) // ============================================================ -// 시스템 프롬프트. // 기본 권장 모델은 `gemma4:e4b` (non-reasoning). 기본값으로 thinking mode가 // 꺼져 있어 추가 토큰이 필요 없지만, 사용자가 수동으로 qwen3/deepseek-r1 등 // reasoning 모델로 교체했을 때를 대비한 2중 방어: @@ -79,30 +79,6 @@ interface LocalLLMEvents { // (3) `stripReasoningBlocks()` 출력 가드 const NO_THINK = '/no_think' -const SYSTEM_PROMPTS: Record = { - refine: `${NO_THINK} -다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요. -원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요. -다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.`, - - translate: `${NO_THINK} -다음 텍스트를 {{targetLanguage}}로 번역해주세요. -자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.`, - - summarize: `${NO_THINK} -다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요. -요약문만 출력하세요.`, - - grammar: `${NO_THINK} -다음 텍스트의 문법 오류만 수정해주세요. -원래 의미와 톤을 유지하면서 문법 오류만 수정하세요. -수정된 텍스트만 출력하세요.`, - - expand: `${NO_THINK} -다음 텍스트를 더 자세하고 풍부하게 확장해주세요. -확장된 텍스트만 출력하세요.` -} - /** * Reasoning model(qwen3, deepseek-r1 등)이 응답에 포함하는 * ... 블록을 제거한다. /no_think 토큰을 무시하는 @@ -453,18 +429,8 @@ class LocalLLMService extends EventEmitter { // LicenseService 미초기화 시 허용 } - let systemPrompt: string - - if (action === 'custom' && customPrompt) { - systemPrompt = customPrompt - } else if (action === 'translate') { - systemPrompt = SYSTEM_PROMPTS.translate.replace( - '{{targetLanguage}}', - targetLanguage ?? 'English' - ) - } else { - systemPrompt = SYSTEM_PROMPTS[action] ?? SYSTEM_PROMPTS.refine - } + const basePrompt = resolveSystemPrompt(action, targetLanguage, customPrompt) + const systemPrompt = `${NO_THINK}\n${basePrompt}` const result = await this.generate(text, { systemPrompt }) const cleaned = stripReasoningBlocks(result.text) diff --git a/apps/desktop/src/main/services/PremiumLLMService.ts b/apps/desktop/src/main/services/PremiumLLMService.ts new file mode 100644 index 0000000..2f80324 --- /dev/null +++ b/apps/desktop/src/main/services/PremiumLLMService.ts @@ -0,0 +1,284 @@ +// src/main/services/PremiumLLMService.ts +// Phase 3.2: Anthropic Claude 프리미엄 LLM 서비스. +// LocalLLMService와 같은 인터페이스를 제공하되, 내부적으로는 +// Supabase Edge Function(`llm-proxy`)을 경유해 Claude Messages API를 호출. +// +// 특징: +// - 싱글톤 + EventEmitter (설계서 01 패턴) +// - processText(), chatStream() — LocalLLMService와 시그니처 동일 +// - 네트워크 실패 / 401 / 429 / 5xx 감지 시 에러 throw → LLMRouterService가 local로 fallback +// - quota-warning / upgrade-required / fallback-triggered 이벤트 emit + +import { EventEmitter } from 'events' +import { getLogger } from './LoggerService' +import { getCloudSyncService } from './CloudSyncService' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import type { LLMAction } from '@d3ro/core/types' +import { resolveSystemPrompt } from './llm-prompts' + +const logger = getLogger('PremiumLLMService') + +// ============================================================ +// 내부 타입 +// ============================================================ + +interface PremiumGenerateOptions { + model?: string + temperature?: number + maxTokens?: number + systemPrompt?: string +} + +/** Ollama-style 메시지 → Claude Messages 변환용 */ +interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +/** llm-proxy Edge Function 요청 body */ +interface LlmProxyRequest { + messages: ChatMessage[] + system?: string + max_tokens?: number + model?: string + stream?: boolean +} + +/** Claude Messages API 비스트리밍 응답 */ +interface ClaudeMessageResponse { + id: string + model: string + role: 'assistant' + content: Array<{ type: 'text'; text: string }> + stop_reason: string + usage: { input_tokens: number; output_tokens: number } +} + +/** llm-proxy 에러 응답 */ +interface LlmProxyErrorResponse { + error: string + current?: number + limit?: number + tier?: 'free' | 'pro' | 'pro_plus' + overage_credits?: number + allowed?: string[] + requested?: string +} + +export interface QuotaUsageSnapshot { + tier: 'free' | 'pro' | 'pro_plus' + current: number + limit: number + overageCredits: number +} + +interface PremiumLLMEvents { + 'quota-warning': (payload: { current: number; limit: number; overageCredits: number }) => void + 'upgrade-required': (payload: { reason: 'quota_exceeded' | 'model_not_allowed' | 'auth_required' }) => void + 'fallback-triggered': (payload: { reason: string }) => void +} + +// ============================================================ +// PremiumLLMService +// ============================================================ + +class PremiumLLMService extends EventEmitter { + private _abortController: AbortController | null = null + private _disposed = false + private _lastQuota: QuotaUsageSnapshot | null = null + + /** + * 사용 가능 여부 — Supabase URL + access token 모두 있어야 true. + * (LLMRouter가 local/premium 분기 시 호출) + */ + isAvailable(): boolean { + const cloud = getCloudSyncService() + return cloud.isEnabled() && cloud.isAuthenticated() + } + + /** + * 마지막 응답에서 서버가 알려준 쿼터 상태. + * Settings UI에서 "오늘 X/250" 표시에 사용. + */ + getLastQuota(): QuotaUsageSnapshot | null { + return this._lastQuota + } + + /** + * disposed / 미인증 상태 공통 가드. + * 문제 시 D3ROError throw, upgrade-required emit. + */ + private _ensureAuth(): void { + if (this._disposed) { + throw new D3ROError(ErrorCode.LLMProcessingFailed, 'PremiumLLMService disposed') + } + const cloud = getCloudSyncService() + if (!cloud.isEnabled() || !cloud.isAuthenticated()) { + this.emit('upgrade-required', { reason: 'auth_required' }) + throw new D3ROError( + ErrorCode.LLMServerUnreachable, + 'Premium LLM 사용 전 로그인 필요', + ) + } + } + + /** + * 텍스트 액션 처리 — LocalLLMService.processText 시그니처 동일. + * Phase 3.2 MVP는 비스트리밍 (stream=false). + */ + async processText( + text: string, + action: LLMAction, + targetLanguage?: string, + customPrompt?: string, + ): Promise { + this._ensureAuth() + + const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt) + + const body: LlmProxyRequest = { + messages: [{ role: 'user', content: text }], + system: systemPrompt, + max_tokens: 2048, + stream: false, + } + + try { + const response = await this._invokeProxy(body) + // Claude 응답 → text 추출 + const firstBlock = response.content?.[0] + if (!firstBlock || firstBlock.type !== 'text') { + logger.warn('Premium LLM returned empty content — falling back to original') + return text + } + const result = firstBlock.text.trim() + if (result.length === 0) { + logger.warn('Premium LLM returned empty text — falling back to original') + return text + } + return result + } catch (err) { + // LLMRouter가 local fallback 처리. 여기서는 에러 전파. + if (err instanceof D3ROError) throw err + throw new D3ROError( + ErrorCode.LLMProcessingFailed, + `Premium LLM failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + /** + * 스트리밍 대화 (Voice Conversation용) — LocalLLMService.chatStream 시그니처 동일. + * Phase 3.2 MVP: 비스트리밍으로 받은 응답을 한 번에 yield. + * 추후 SSE 스트림 파싱으로 실시간 토큰 yield로 업그레이드 예정. + */ + async *chatStream( + messages: Array<{ role: string; content: string }>, + options?: { model?: string; temperature?: number }, + ): AsyncGenerator { + this._ensureAuth() + + // Ollama 메시지 형식 → Claude Messages 형식으로 정규화 + const claudeMessages: ChatMessage[] = messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })) + + const body: LlmProxyRequest = { + messages: claudeMessages, + model: options?.model, + max_tokens: 2048, + stream: false, + } + + const response = await this._invokeProxy(body) + const firstBlock = response.content?.[0] + const text = firstBlock?.type === 'text' ? firstBlock.text : '' + + // 단일 chunk yield — 추후 SSE 스트리밍으로 업그레이드 시 여러 번 yield. + if (text.length > 0) { + yield text + } + return text + } + + cancelGeneration(): void { + if (this._abortController) { + this._abortController.abort() + this._abortController = null + logger.info('Premium LLM generation cancelled') + } + } + + dispose(): void { + this._disposed = true + this.cancelGeneration() + this.removeAllListeners() + logger.info('PremiumLLMService disposed') + } + + // ── 내부: Edge Function 호출 ───────────────────────────── + + private async _invokeProxy(body: LlmProxyRequest): Promise { + const cloud = getCloudSyncService() + + // Supabase JS 클라이언트의 functions.invoke() 사용 — auth 헤더를 올바르게 처리. + // raw fetch + Authorization: Bearer 방식은 Supabase gateway가 401로 거부. + const { data, error } = await cloud.invokeFunction('llm-proxy', body as unknown as Record) + + if (error) { + const msg = error.message ?? 'Edge Function error' + logger.error(`llm-proxy error: ${msg}`) + + // 에러 메시지 기반 분류 + if (msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth')) { + this.emit('upgrade-required', { reason: 'auth_required' }) + throw new D3ROError(ErrorCode.LLMServerUnreachable, `인증 실패: ${msg}`) + } + if (msg.includes('quota_exceeded') || msg.includes('429')) { + this.emit('upgrade-required', { reason: 'quota_exceeded' }) + throw new D3ROError(ErrorCode.LLMProcessingFailed, `쿼터 초과: ${msg}`) + } + if (msg.includes('model_not_allowed') || msg.includes('403')) { + this.emit('upgrade-required', { reason: 'model_not_allowed' }) + throw new D3ROError(ErrorCode.LLMInvalidAction, `모델 권한 없음: ${msg}`) + } + + throw new D3ROError(ErrorCode.LLMProcessingFailed, `llm-proxy: ${msg}`) + } + + // functions.invoke는 response body를 자동 파싱해서 data에 넣음 + const result = data as ClaudeMessageResponse + if (!result?.content) { + logger.warn('llm-proxy returned unexpected shape — falling back') + throw new D3ROError(ErrorCode.LLMProcessingFailed, 'llm-proxy returned invalid response') + } + + return result + } + + // ── EventEmitter 타입 오버라이드 ─────────────────────── + + on(event: K, listener: PremiumLLMEvents[K]): this { + return super.on(event, listener) + } + + emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } +} + +// ── 싱글톤 ───────────────────────────────────────────────── + +let _instance: PremiumLLMService | null = null + +export function getPremiumLLMService(): PremiumLLMService { + if (!_instance) { + _instance = new PremiumLLMService() + } + return _instance +} + +export type { PremiumLLMService } diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index e393825..aa4b56f 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -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 } + 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 { + 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 { 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 diff --git a/apps/desktop/src/main/services/llm-prompts.ts b/apps/desktop/src/main/services/llm-prompts.ts new file mode 100644 index 0000000..35e99b8 --- /dev/null +++ b/apps/desktop/src/main/services/llm-prompts.ts @@ -0,0 +1,50 @@ +// src/main/services/llm-prompts.ts +// LLM 시스템 프롬프트 SSOT — LocalLLMService / PremiumLLMService 공용. + +import type { LLMAction } from '@d3ro/core/types' + +/** + * 기본 시스템 프롬프트 (NO_THINK prefix 없음). + * PremiumLLMService는 그대로, LocalLLMService는 NO_THINK를 prepend해서 사용. + */ +const BASE_SYSTEM_PROMPTS: Record = { + refine: `다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요. +원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요. +다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.`, + + translate: `다음 텍스트를 {{targetLanguage}}로 번역해주세요. +자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.`, + + summarize: `다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요. +요약문만 출력하세요.`, + + grammar: `다음 텍스트의 문법 오류만 수정해주세요. +원래 의미와 톤을 유지하면서 문법 오류만 수정하세요. +수정된 텍스트만 출력하세요.`, + + expand: `다음 텍스트를 더 자세하고 풍부하게 확장해주세요. +확장된 텍스트만 출력하세요.`, +} + +/** + * 액션 + 옵션으로 시스템 프롬프트를 해석한다. + * translate 액션은 targetLanguage 치환, custom 액션은 customPrompt 사용. + */ +export function resolveSystemPrompt( + action: LLMAction, + targetLanguage?: string, + customPrompt?: string, +): string { + if (action === 'custom' && customPrompt) { + return customPrompt + } + if (action === 'translate') { + return BASE_SYSTEM_PROMPTS.translate.replace( + '{{targetLanguage}}', + targetLanguage ?? 'English', + ) + } + return BASE_SYSTEM_PROMPTS[action] ?? BASE_SYSTEM_PROMPTS.refine +} + +export { BASE_SYSTEM_PROMPTS } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 1ebb0ff..673e709 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -299,7 +299,29 @@ const electronAPI = { onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb), onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe => - on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb) + on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb), + // Phase 3.2: Premium LLM + premium: { + getStatus: () => + invoke<{ available: boolean; backend: 'local' | 'premium' }>( + IPC_CHANNELS.LLM.PREMIUM_GET_STATUS + ), + getQuota: () => + invoke<{ + tier: 'free' | 'pro' | 'pro_plus' + current: number + limit: number + overageCredits: number + } | null>(IPC_CHANNELS.LLM.PREMIUM_GET_QUOTA), + onFallback: (cb: (e: { reason: string }) => void): Unsubscribe => + on(IPC_CHANNELS.LLM.PREMIUM_FALLBACK, cb), + onQuotaWarning: ( + cb: (e: { current: number; limit: number; overageCredits: number }) => void + ): Unsubscribe => on(IPC_CHANNELS.LLM.PREMIUM_QUOTA_WARNING, cb), + onUpgradeRequired: ( + cb: (e: { reason: 'quota_exceeded' | 'model_not_allowed' | 'auth_required' }) => void + ): Unsubscribe => on(IPC_CHANNELS.LLM.PREMIUM_UPGRADE_REQUIRED, cb), + }, }, // ── History ──────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx index 0db4985..ba8f29f 100644 --- a/apps/desktop/src/renderer/components/AppLayout.tsx +++ b/apps/desktop/src/renderer/components/AppLayout.tsx @@ -2,7 +2,7 @@ // 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역 import { useState, useEffect } from 'react' -import { Box, Typography, Tooltip } from '@mui/material' +import { Alert, Box, Snackbar, Typography, Tooltip } from '@mui/material' import DashboardIcon from '@mui/icons-material/Dashboard' import HistoryIcon from '@mui/icons-material/History' import MenuBookIcon from '@mui/icons-material/MenuBook' @@ -62,6 +62,8 @@ export function AppLayout(): React.ReactElement { const [onboardingOpen, setOnboardingOpen] = useState(false) const [licenseModalOpen, setLicenseModalOpen] = useState(false) const [currentTier, setCurrentTier] = useState('free') + // Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled) + const [fallbackMsg, setFallbackMsg] = useState(null) // 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시 useEffect(() => { @@ -89,9 +91,19 @@ export function AppLayout(): React.ReactElement { const handleOpenLicenseModal = () => setLicenseModalOpen(true) window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal) + // Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독 + const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => { + setFallbackMsg(e.reason) + }) + const unsubUpgradeReq = window.electronAPI.llm.premium.onUpgradeRequired(() => { + setLicenseModalOpen(true) + }) + return () => { unsubTier() unsubUpgrade() + unsubFallback() + unsubUpgradeReq() window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal) } }, []) @@ -232,6 +244,18 @@ export function AppLayout(): React.ReactElement { setSettingsOpen(false)} /> setLicenseModalOpen(false)} /> setOnboardingOpen(false)} /> + + {/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */} + setFallbackMsg(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setFallbackMsg(null)} sx={{ width: '100%' }}> + {fallbackMsg} + + ) } diff --git a/apps/desktop/src/renderer/components/LicenseModal.tsx b/apps/desktop/src/renderer/components/LicenseModal.tsx index 5ec0678..5766e31 100644 --- a/apps/desktop/src/renderer/components/LicenseModal.tsx +++ b/apps/desktop/src/renderer/components/LicenseModal.tsx @@ -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(null) const [tierComparison, setTierComparison] = useState([]) const [usageQuotas, setUsageQuotas] = useState([]) - const [keyInput, setKeyInput] = useState('') - const [activating, setActivating] = useState(false) - const [activateMessage, setActivateMessage] = useState(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 ( - + {/* ---- Current Tier ---- */} - + @@ -178,92 +134,65 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE - {/* ---- Activate / Info ---- */} - - {isFree ? ( - // Free tier: show activation form - - {t('license.activate')} - - 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, - }, - }} - /> - - {activating ? ( - - ) : ( - t('license.activate') - )} + {/* ---- Subscription Management ---- */} + + + {t('license.subscribe')} + + {isFree && ( + <> + + + + + {t('license.upgrade')} — {t('license.proPlan')} + + - - {activateMessage && ( - - {activateMessage} + + + + + {t('license.upgrade')} — {t('license.proPlusPlan')} + + + + + )} + + {isPro && ( + <> + + + + {t('license.currentPlan')} — {t('license.proPlan')} + + + + + + + {t('license.upgrade')} — {t('license.proPlusPlan')} + + + + + )} + + {currentTier === 'pro_plus' && ( + + + + {t('license.currentPlan')} — {t('license.proPlusPlan')} - )} - - ) : ( - // Pro/Pro+: show license info - - {t('license.keyLabel')} - - - - {licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'} - - - - - - - {t('license.activatedAt')} - - {formatDate(licenseInfo?.activatedAt ?? null)} - - - - {t('license.machineId')} - - {licenseInfo?.machineId?.slice(0, 12) ?? '-'}... - - - - - {t('license.deactivate')} - - - )} + )} + {/* ---- Daily Usage ---- */} {usageQuotas.length > 0 && ( - + {t('license.dailyUsage')} {usageQuotas.map((q) => ( @@ -309,7 +238,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE {/* ---- Tier Comparison ---- */} {tierComparison.length > 0 && ( - + {t('license.tierComparison')} @@ -333,7 +262,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE {tierComparison.map((row) => ( - {row.featureLabel} + {t(row.featureLabel as Parameters[0])} {renderTierCell(row.free)} {renderTierCell(row.pro)} diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index 9fe9215..d1d53eb 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -693,43 +693,78 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac - {t('settings.ollamaServer')} - - - updateConfig('ollamaServerUrl', e.target.value)} - fullWidth - /> - - - {t('settings.ollamaHint')} - - - - - - {t('settings.llmModel')} + {t('settings.llmBackend')} - {t('settings.llmModel')} + {t('settings.llmBackend')} + + {config.llmBackend === 'premium' + ? t('settings.backend.premiumHint') + : t('settings.backend.localHint')} + + + {config.llmBackend !== 'premium' && ( + <> + + + + {t('settings.ollamaServer')} + + + updateConfig('ollamaServerUrl', e.target.value)} + fullWidth + /> + + + {t('settings.ollamaHint')} + + + + + + {t('settings.llmModel')} + + + + {t('settings.llmModel')} + + + + )} + diff --git a/packages/api-client/__tests__/types.test.ts b/packages/api-client/__tests__/types.test.ts index e404a2a..a092876 100644 --- a/packages/api-client/__tests__/types.test.ts +++ b/packages/api-client/__tests__/types.test.ts @@ -24,7 +24,7 @@ import type { describe('api-client types', () => { it('SubscriptionTier 리터럴 union', () => { - const tiers: SubscriptionTier[] = ['free', 'pro', 'team'] + const tiers: SubscriptionTier[] = ['free', 'pro', 'pro_plus'] expect(tiers).toHaveLength(3) }) diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index ba8a524..2da2e34 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -1,7 +1,7 @@ // packages/api-client/src/types.ts // Supabase DB row 타입 — V2-2 스키마와 동기화된 수동 정의. -export type SubscriptionTier = 'free' | 'pro' | 'team' +export type SubscriptionTier = 'free' | 'pro' | 'pro_plus' export type Profile = { id: string @@ -148,7 +148,11 @@ export type Subscription = { id: string user_id: string tier: SubscriptionTier + /** Phase 3.2: 베이스 쿼터 소진 시 차감되는 추가 크레딧 */ + overage_credits: number + /** @deprecated Phase 3.2-B (Payple 이관 예정) */ stripe_customer_id: string | null + /** @deprecated Phase 3.2-B (Payple 이관 예정) */ stripe_subscription_id: string | null status: string | null current_period_start: string | null diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts index 77c6659..5d58a94 100644 --- a/packages/core/src/ipc-channels.ts +++ b/packages/core/src/ipc-channels.ts @@ -63,10 +63,19 @@ export const IPC_CHANNELS = { GET_SERVER_URL: 'llm:getServerUrl', SET_SERVER_URL: 'llm:setServerUrl', PULL_MODEL: 'llm:pullModel', + // Phase 3.2: Premium LLM (Supabase llm-proxy → Claude) + PREMIUM_GET_STATUS: 'llm:premium:getStatus', + PREMIUM_GET_QUOTA: 'llm:premium:getQuota', // Main → Renderer events STATUS_CHANGED: 'llm:statusChanged', PROCESS_PROGRESS: 'llm:processProgress', - PULL_PROGRESS: 'llm:pullProgress' + PULL_PROGRESS: 'llm:pullProgress', + /** Phase 3.2: Premium LLM 호출 실패 → Local 자동 fallback 알림 */ + PREMIUM_FALLBACK: 'llm:premiumFallback', + /** Phase 3.2: 쿼터 경고 (80%+ 또는 초과) */ + PREMIUM_QUOTA_WARNING: 'llm:premiumQuotaWarning', + /** Phase 3.2: 업그레이드 요구 (쿼터 초과 / 모델 권한 / 인증) */ + PREMIUM_UPGRADE_REQUIRED: 'llm:premiumUpgradeRequired' }, HOTKEY: { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a73907b..bf3e3cd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -366,6 +366,12 @@ export interface AppConfig { ttsSpeed: number ollamaServerUrl: string llmModelId: string | null + /** + * Phase 3.2: LLM 백엔드 선택. + * 'local' — LocalLLMService (Ollama, 기본값, 무료) + * 'premium' — PremiumLLMService (Supabase llm-proxy → Claude, 로그인+구독 필요) + */ + llmBackend: 'local' | 'premium' defaultLLMAction: LLMAction dictationShortcut: HotkeyBinding handsFreeShortcut: HotkeyBinding diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 0b74e1a..e167542 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -123,6 +123,11 @@ "settings.ollamaServer": "Ollama Server", "settings.ollamaUrl": "Ollama Server URL", "settings.ollamaHint": "Connects automatically when Ollama is running. Pull models directly in Ollama (e.g. ollama pull gemma4:e4b).", + "settings.llmBackend": "LLM Engine", + "settings.backend.local": "Local (Ollama, Free)", + "settings.backend.premium": "Premium (Claude AI, Subscription)", + "settings.backend.localHint": "Polish text with local Ollama server. Fully free, no internet needed.", + "settings.backend.premiumHint": "High-quality polishing with Anthropic Claude AI. Login + subscription required. Auto-fallback to Local on network failure.", "settings.llmModel": "LLM Model", "settings.postProcess": "Voice Post-Processing", "settings.defaultAction": "Default Post-Processing Command", @@ -276,7 +281,12 @@ "license.machineId": "Machine ID", "license.activatedAt": "Activated At", "license.manageLicense": "Manage License", + "license.subscribe": "Subscribe", "license.upgrade": "Upgrade", + "license.currentPlan": "Current Plan", + "license.proPlan": "Pro — ₩9,900/mo", + "license.proPlusPlan": "Pro+ — ₩29,900/mo", + "license.paymentPending": "Payment integration coming soon (Payple)", "license.upgradeTitle": "Upgrade to Pro", "license.upgradeDesc": "Unlock all features", "license.quotaUsed": "{{used}}/{{limit}} used", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 280c21f..7988f12 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -124,6 +124,11 @@ "settings.ollamaServer": "Ollama 서버", "settings.ollamaUrl": "Ollama 서버 URL", "settings.ollamaHint": "Ollama가 실행 중이면 자동으로 연결됩니다. 모델은 Ollama에서 직접 pull하세요 (예: ollama pull gemma4:e4b).", + "settings.llmBackend": "LLM 엔진", + "settings.backend.local": "Local (Ollama, 무료)", + "settings.backend.premium": "Premium (Claude AI, 구독)", + "settings.backend.localHint": "로컬 Ollama 서버로 텍스트를 다듬습니다. 완전 무료, 인터넷 불필요.", + "settings.backend.premiumHint": "Anthropic Claude AI로 고품질 다듬기. 로그인 + 구독 필요. 네트워크 실패 시 Local로 자동 전환.", "settings.llmModel": "LLM 모델", "settings.postProcess": "음성 후처리", "settings.defaultAction": "기본 후처리 명령어", @@ -277,7 +282,12 @@ "license.machineId": "기기 ID", "license.activatedAt": "활성화 일시", "license.manageLicense": "라이선스 관리", + "license.subscribe": "구독하기", "license.upgrade": "업그레이드", + "license.currentPlan": "현재 구독 중", + "license.proPlan": "Pro — ₩9,900/월", + "license.proPlusPlan": "Pro+ — ₩29,900/월", + "license.paymentPending": "결제 연동 준비 중 (Payple)", "license.upgradeTitle": "Pro로 업그레이드", "license.upgradeDesc": "모든 기능을 잠금 해제하세요", "license.quotaUsed": "{{used}}/{{limit}} 사용", diff --git a/server/supabase/config.toml b/server/supabase/config.toml index 994a533..16d3748 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -82,7 +82,8 @@ inspector_port = 8083 verify_jwt = true [functions.llm-proxy] -verify_jwt = true +# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증 +verify_jwt = false [functions.stripe-checkout] verify_jwt = true diff --git a/server/supabase/functions/_shared/quota.ts b/server/supabase/functions/_shared/quota.ts index 0e99ce7..088359c 100644 --- a/server/supabase/functions/_shared/quota.ts +++ b/server/supabase/functions/_shared/quota.ts @@ -1,97 +1,182 @@ // server/supabase/functions/_shared/quota.ts -// 티어별 기능 쿼터 확인 + 증가 +// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리 +// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한 // @ts-expect-error — Deno 런타임 import import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7' -export type Tier = 'free' | 'pro' | 'team' -export type Feature = 'stt_transcribe' | 'llm_process' +export type Tier = 'free' | 'pro' | 'pro_plus' -/** 일일 쿼터 정책 (-1 = 무제한) */ -const DAILY_QUOTA: Record> = { +/** 쿼터 추적 키 — 모델별 분리 */ +export type QuotaFeature = + | 'stt_transcribe' + | 'llm_haiku' + | 'llm_sonnet' + | 'llm_opus' + +export type QuotaPeriod = 'daily' | 'weekly' + +interface ModelQuota { + /** -1=무제한, 0=사용불가, 양수=한도 */ + limit: number + period: QuotaPeriod +} + +/** 모델별 쿼터 정책 */ +const MODEL_QUOTA: Record> = { free: { - stt_transcribe: 50, - llm_process: 50 + stt_transcribe: { limit: 250, period: 'weekly' }, + llm_haiku: { limit: 250, period: 'weekly' }, + llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가 + llm_opus: { limit: 0, period: 'daily' }, // 사용불가 }, pro: { - stt_transcribe: -1, - llm_process: -1 + stt_transcribe: { limit: -1, period: 'daily' }, + llm_haiku: { limit: 1500, period: 'daily' }, + llm_sonnet: { limit: 300, period: 'daily' }, + llm_opus: { limit: 50, period: 'daily' }, }, - team: { - stt_transcribe: -1, - llm_process: -1 - } + pro_plus: { + stt_transcribe: { limit: -1, period: 'daily' }, + llm_haiku: { limit: -1, period: 'daily' }, // 무제한 + llm_sonnet: { limit: 1500, period: 'daily' }, + llm_opus: { limit: 300, period: 'daily' }, + }, +} + +/** Anthropic 모델명 → 쿼터 키 매핑 */ +export function modelToQuotaKey(model: string): QuotaFeature { + if (model.includes('haiku')) return 'llm_haiku' + if (model.includes('sonnet')) return 'llm_sonnet' + if (model.includes('opus')) return 'llm_opus' + return 'llm_haiku' // fallback +} + +/** 티어+feature → 쿼터 정책 조회 */ +export function getQuotaPolicy(tier: Tier, feature: QuotaFeature): ModelQuota { + return MODEL_QUOTA[tier]?.[feature] ?? { limit: 0, period: 'daily' } } export interface QuotaCheck { allowed: boolean current: number limit: number + period: QuotaPeriod tier: Tier + overageCredits: number +} + +export interface QuotaConsumeResult { + allowed: boolean + current: number + limit: number + overageCredits: number + consumedFrom: 'base' | 'overage' | 'unlimited' | 'none' } /** - * 유저의 오늘 사용량을 확인하고 쿼터 초과 여부를 반환. - * 실제 증가는 performQuotaConsume 호출 시 수행. + * 쿼터 확인 — 모델별, 기간별(daily/weekly). + * weekly인 경우 최근 7일 daily_usage를 합산. */ export async function checkQuota( userId: string, - feature: Feature, - serviceRoleClient: ReturnType + feature: QuotaFeature, + serviceRoleClient: ReturnType, ): Promise { - // 티어 조회 + // 티어 + overage 조회 const { data: sub } = await serviceRoleClient .from('subscriptions') - .select('tier') + .select('tier, overage_credits') .eq('user_id', userId) .single() const tier: Tier = (sub?.tier as Tier) ?? 'free' - const limit = DAILY_QUOTA[tier][feature] + const overageCredits = (sub?.overage_credits as number) ?? 0 + const policy = getQuotaPolicy(tier, feature) - if (limit === -1) { - return { allowed: true, current: 0, limit, tier } + // 사용불가 (limit=0) + if (policy.limit === 0) { + return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits } } - // 오늘 사용량 조회 - const today = new Date().toISOString().slice(0, 10) - const { data: usage } = await serviceRoleClient - .from('daily_usage') - .select('count') - .eq('user_id', userId) - .eq('date', today) - .eq('feature', feature) - .maybeSingle() + // 무제한 + if (policy.limit === -1) { + return { allowed: true, current: 0, limit: -1, period: policy.period, tier, overageCredits } + } + + // 사용량 조회 (daily vs weekly) + let current: number + if (policy.period === 'weekly') { + // 최근 7일 합산 + const weekAgo = new Date() + weekAgo.setDate(weekAgo.getDate() - 7) + const { data: rows } = await serviceRoleClient + .from('daily_usage') + .select('count') + .eq('user_id', userId) + .eq('feature', feature) + .gte('date', weekAgo.toISOString().slice(0, 10)) + current = rows?.reduce((sum: number, row: { count: number }) => sum + row.count, 0) ?? 0 + } else { + // 오늘만 + const today = new Date().toISOString().slice(0, 10) + const { data: usage } = await serviceRoleClient + .from('daily_usage') + .select('count') + .eq('user_id', userId) + .eq('date', today) + .eq('feature', feature) + .maybeSingle() + current = (usage?.count as number) ?? 0 + } - const current = (usage?.count as number) ?? 0 return { - allowed: current < limit, + allowed: current < policy.limit || overageCredits > 0, current, - limit, - tier + limit: policy.limit, + period: policy.period, + tier, + overageCredits, } } /** - * 쿼터 소비. increment_daily_usage 함수 호출 (service_role 전용). + * 쿼터 소비 — 항상 오늘 날짜의 daily_usage를 +1 증가. + * (weekly 집계는 checkQuota에서 7일 합산으로 처리) + * 무제한(-1)이면 카운터만 증가하고 allowed=true. + * base 소진 + overage 있으면 overage 차감. */ export async function consumeQuota( userId: string, - feature: Feature, + feature: QuotaFeature, serviceRoleClient: ReturnType, - amount: number = 1 -): Promise { - const { data, error } = await serviceRoleClient.rpc('increment_daily_usage', { + baseLimit: number, +): Promise { + const { data, error } = await serviceRoleClient.rpc('consume_quota', { p_user_id: userId, p_feature: feature, - p_amount: amount + p_base_limit: baseLimit, }) if (error) { - throw new Error(`Failed to increment quota: ${error.message}`) + throw new Error(`Failed to consume quota: ${error.message}`) } - return (data as number) ?? 0 + const result = data as { + allowed: boolean + current: number + limit: number + overage_credits: number + consumed_from: 'base' | 'overage' | 'unlimited' | 'none' + } + + return { + allowed: result.allowed, + current: result.current, + limit: result.limit, + overageCredits: result.overage_credits, + consumedFrom: result.consumed_from, + } } /** @@ -103,6 +188,6 @@ export function createServiceRoleClient(): ReturnType { // @ts-expect-error — Deno.env는 Deno 런타임 전역 const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' return createClient(url, serviceKey, { - auth: { persistSession: false, autoRefreshToken: false } + auth: { persistSession: false, autoRefreshToken: false }, }) } diff --git a/server/supabase/functions/llm-proxy/index.ts b/server/supabase/functions/llm-proxy/index.ts index 0b9d49b..9cb2080 100644 --- a/server/supabase/functions/llm-proxy/index.ts +++ b/server/supabase/functions/llm-proxy/index.ts @@ -1,11 +1,19 @@ // server/supabase/functions/llm-proxy/index.ts // Anthropic Claude Messages API 프록시. +// Phase 3.2: 모델별 쿼터 (Haiku/Sonnet/Opus × Free/Pro/Pro+) // 요청: application/json { messages, system?, max_tokens?, model? } // 응답: JSON (non-stream) 또는 SSE (stream=true) import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' -import { checkQuota, consumeQuota, createServiceRoleClient, type Tier } from '../_shared/quota.ts' +import { + checkQuota, + consumeQuota, + createServiceRoleClient, + modelToQuotaKey, + getQuotaPolicy, + type Tier, +} from '../_shared/quota.ts' interface LlmRequest { messages: Array<{ role: 'user' | 'assistant'; content: string }> @@ -15,17 +23,17 @@ interface LlmRequest { stream?: boolean } -/** 티어별 허용 모델 */ +/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus */ const TIER_MODELS: Record = { free: ['claude-haiku-4-5-20251001'], - pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'], - team: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'] + pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'], + pro_plus: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'], } const DEFAULT_MODEL: Record = { free: 'claude-haiku-4-5-20251001', pro: 'claude-sonnet-4-6', - team: 'claude-sonnet-4-6' + pro_plus: 'claude-sonnet-4-6', } // @ts-expect-error — Deno 런타임 전역 @@ -36,45 +44,75 @@ Deno.serve(async (req: Request) => { if (req.method !== 'POST') { return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } try { const user = await requireUser(req) const serviceClient = createServiceRoleClient() - const quota = await checkQuota(user.id, 'llm_process', serviceClient) - if (!quota.allowed) { - return new Response( - JSON.stringify({ - error: 'quota_exceeded', - current: quota.current, - limit: quota.limit, - tier: quota.tier - }), - { - status: 429, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - } - ) - } const body = (await req.json()) as LlmRequest + // 1단계: 티어 조회 (어떤 모델이든 한 번만 읽으면 됨 — haiku로 대리 조회) + const tierCheck = await checkQuota(user.id, 'llm_haiku', serviceClient) + const tier = tierCheck.tier + // 모델 선택 + 티어 검증 - const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier] - if (!TIER_MODELS[quota.tier].includes(requestedModel)) { + const requestedModel = body.model ?? DEFAULT_MODEL[tier] + if (!TIER_MODELS[tier].includes(requestedModel)) { return new Response( JSON.stringify({ error: 'model_not_allowed', - tier: quota.tier, + tier, requested: requestedModel, - allowed: TIER_MODELS[quota.tier] + allowed: TIER_MODELS[tier], }), { status: 403, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - } + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ) + } + + // 2단계: 해당 모델의 쿼터 확인 (모델별 일간/주간) + const quotaKey = modelToQuotaKey(requestedModel) + const modelQuota = await checkQuota(user.id, quotaKey, serviceClient) + if (!modelQuota.allowed) { + return new Response( + JSON.stringify({ + error: 'quota_exceeded', + model: requestedModel, + current: modelQuota.current, + limit: modelQuota.limit, + period: modelQuota.period, + tier, + overage_credits: modelQuota.overageCredits, + }), + { + status: 429, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ) + } + + // 3단계: 쿼터 소비 (원자적 base → overage fallback) + const policy = getQuotaPolicy(tier, quotaKey) + const consume = await consumeQuota(user.id, quotaKey, serviceClient, policy.limit) + if (!consume.allowed) { + return new Response( + JSON.stringify({ + error: 'quota_exceeded', + model: requestedModel, + current: consume.current, + limit: consume.limit, + tier, + overage_credits: consume.overageCredits, + }), + { + status: 429, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, ) } @@ -82,12 +120,9 @@ Deno.serve(async (req: Request) => { // @ts-expect-error — Deno.env const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? '' - await consumeQuota(user.id, 'llm_process', serviceClient, 1) - if (!anthropicKey) { // Placeholder 응답 (키 미설정 시) if (body.stream) { - // 스트리밍 placeholder — SSE로 "설정되지 않음" 메시지 전송 const encoder = new TextEncoder() const stream = new ReadableStream({ start(controller) { @@ -95,13 +130,13 @@ Deno.serve(async (req: Request) => { for (const ch of msg) { controller.enqueue( encoder.encode( - `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n` - ) + `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n`, + ), ) } controller.enqueue(encoder.encode('data: [DONE]\n\n')) controller.close() - } + }, }) return new Response(stream, { status: 200, @@ -109,8 +144,8 @@ Deno.serve(async (req: Request) => { ...corsHeaders, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - } + Connection: 'keep-alive', + }, }) } @@ -122,16 +157,16 @@ Deno.serve(async (req: Request) => { content: [ { type: 'text', - text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]' - } + text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]', + }, ], stop_reason: 'end_turn', - usage: { input_tokens: 0, output_tokens: 0 } + usage: { input_tokens: 0, output_tokens: 0 }, }), { status: 200, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - } + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, ) } @@ -141,15 +176,15 @@ Deno.serve(async (req: Request) => { headers: { 'Content-Type': 'application/json', 'x-api-key': anthropicKey, - 'anthropic-version': '2023-06-01' + 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: requestedModel, max_tokens: body.max_tokens ?? 2048, system: body.system, messages: body.messages, - stream: body.stream ?? false - }) + stream: body.stream ?? false, + }), }) if (!anthropicResp.ok) { @@ -158,22 +193,21 @@ Deno.serve(async (req: Request) => { } if (body.stream && anthropicResp.body) { - // SSE 스트림을 그대로 전달 return new Response(anthropicResp.body, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - } + Connection: 'keep-alive', + }, }) } const data = await anthropicResp.json() return new Response(JSON.stringify(data), { status: 200, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } catch (err) { if (err && typeof err === 'object' && 'status' in err && 'message' in err) { @@ -182,7 +216,7 @@ Deno.serve(async (req: Request) => { const message = err instanceof Error ? err.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } }) diff --git a/server/supabase/functions/stt-proxy/index.ts b/server/supabase/functions/stt-proxy/index.ts index 795e8eb..b3bec1a 100644 --- a/server/supabase/functions/stt-proxy/index.ts +++ b/server/supabase/functions/stt-proxy/index.ts @@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => { } // 5) 쿼터 소비 - await consumeQuota(user.id, 'stt_transcribe', serviceClient, 1) + await consumeQuota(user.id, 'stt_transcribe', serviceClient, quota.limit) return new Response(JSON.stringify(placeholder), { status: 200, diff --git a/server/supabase/migrations/20260412000001_tier_unification_and_overage.sql b/server/supabase/migrations/20260412000001_tier_unification_and_overage.sql new file mode 100644 index 0000000..9585055 --- /dev/null +++ b/server/supabase/migrations/20260412000001_tier_unification_and_overage.sql @@ -0,0 +1,150 @@ +-- ============================================================================ +-- Phase 3.2: Tier 통일 (team → pro_plus) + 오버리지 크레딧 데이터 모델 +-- ============================================================================ +-- 데스크톱 코드가 이미 'pro_plus'를 사용 중이고 서버만 'team'이 남아있어 +-- 발생한 불일치를 해소. 팀 협업 feature(teams/team_members 테이블)와 +-- 가격제(tier) 개념은 분리 — 이 마이그레이션은 가격제만 건드린다. +-- +-- 추가로 SaaS 오버리지 구매 모델을 위한 subscriptions.overage_credits 컬럼 +-- 도입. 실제 Stripe 연결은 Phase 3.3 이월, 이번 마이그레이션은 데이터 모델과 +-- 읽기 경로만 준비. + +-- ---------------------------------------------------------------------------- +-- 1. profiles.tier: 기존 CHECK 제약 제거 → 값 마이그레이션 → 새 CHECK +-- ---------------------------------------------------------------------------- +ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_tier_check; + +UPDATE public.profiles SET tier = 'pro_plus' WHERE tier = 'team'; + +ALTER TABLE public.profiles + ADD CONSTRAINT profiles_tier_check + CHECK (tier IN ('free', 'pro', 'pro_plus')); + +-- ---------------------------------------------------------------------------- +-- 2. subscriptions.tier: 동일 처리 +-- ---------------------------------------------------------------------------- +ALTER TABLE public.subscriptions DROP CONSTRAINT IF EXISTS subscriptions_tier_check; + +UPDATE public.subscriptions SET tier = 'pro_plus' WHERE tier = 'team'; + +ALTER TABLE public.subscriptions + ADD CONSTRAINT subscriptions_tier_check + CHECK (tier IN ('free', 'pro', 'pro_plus')); + +-- ---------------------------------------------------------------------------- +-- 3. subscriptions.overage_credits: SaaS 오버리지 구매 모델 +-- ---------------------------------------------------------------------------- +ALTER TABLE public.subscriptions + ADD COLUMN IF NOT EXISTS overage_credits integer NOT NULL DEFAULT 0; + +COMMENT ON COLUMN public.subscriptions.overage_credits IS + '추가 크레딧 (베이스 일일 쿼터 소진 시 차감). Phase 3.2는 데이터 모델만, 실제 Stripe 구매 경로는 Phase 3.3.'; + +-- ---------------------------------------------------------------------------- +-- 4. consume_quota RPC: 원자적 base → overage fallback 소비 +-- ---------------------------------------------------------------------------- +-- 기존 increment_daily_usage는 유지 (다른 경로에서 쓰일 수 있음). +-- llm-proxy는 이 새 RPC를 사용해 원자적으로 base → overage 순차 소비. + +CREATE OR REPLACE FUNCTION public.consume_quota( + p_user_id uuid, + p_feature text, + p_base_limit integer +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_current integer; + v_overage integer; + v_new_count integer; + v_new_overage integer; +BEGIN + -- 오늘 사용량 조회 (없으면 0) + SELECT count INTO v_current + FROM public.daily_usage + WHERE user_id = p_user_id + AND feature = p_feature + AND date = CURRENT_DATE; + v_current := COALESCE(v_current, 0); + + -- 오버리지 크레딧 조회 (없으면 0) + SELECT overage_credits INTO v_overage + FROM public.subscriptions + WHERE user_id = p_user_id; + v_overage := COALESCE(v_overage, 0); + + -- 무제한(-1): 그냥 카운터만 증가 + IF p_base_limit = -1 THEN + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (p_user_id, CURRENT_DATE, p_feature, 1) + ON CONFLICT (user_id, date, feature) DO UPDATE + SET count = public.daily_usage.count + 1 + RETURNING count INTO v_new_count; + RETURN jsonb_build_object( + 'allowed', true, + 'current', v_new_count, + 'limit', -1, + 'overage_credits', v_overage, + 'consumed_from', 'unlimited' + ); + END IF; + + -- base 잔여 여부 체크 + IF v_current < p_base_limit THEN + -- base 소비 + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (p_user_id, CURRENT_DATE, p_feature, 1) + ON CONFLICT (user_id, date, feature) DO UPDATE + SET count = public.daily_usage.count + 1 + RETURNING count INTO v_new_count; + RETURN jsonb_build_object( + 'allowed', true, + 'current', v_new_count, + 'limit', p_base_limit, + 'overage_credits', v_overage, + 'consumed_from', 'base' + ); + END IF; + + -- base 소진 → 오버리지 체크 + IF v_overage <= 0 THEN + RETURN jsonb_build_object( + 'allowed', false, + 'current', v_current, + 'limit', p_base_limit, + 'overage_credits', 0, + 'consumed_from', 'none' + ); + END IF; + + -- 오버리지 소비 (daily_usage 증가 + overage_credits 감소) + UPDATE public.subscriptions + SET overage_credits = overage_credits - 1, + updated_at = now() + WHERE user_id = p_user_id + RETURNING overage_credits INTO v_new_overage; + + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (p_user_id, CURRENT_DATE, p_feature, 1) + ON CONFLICT (user_id, date, feature) DO UPDATE + SET count = public.daily_usage.count + 1 + RETURNING count INTO v_new_count; + + RETURN jsonb_build_object( + 'allowed', true, + 'current', v_new_count, + 'limit', p_base_limit, + 'overage_credits', v_new_overage, + 'consumed_from', 'overage' + ); +END; +$$; + +COMMENT ON FUNCTION public.consume_quota(uuid, text, integer) IS + '원자적 쿼터 소비: base 먼저 → 소진 시 overage. Phase 3.2 llm-proxy 전용.'; + +-- RPC는 service_role만 호출 가능 +REVOKE ALL ON FUNCTION public.consume_quota(uuid, text, integer) FROM public; +GRANT EXECUTE ON FUNCTION public.consume_quota(uuid, text, integer) TO service_role;