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
|
|
@ -4,18 +4,38 @@ import { ipcMain } from 'electron'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import { getLocalLLMService } from '../services/LocalLLMService'
|
import { getLocalLLMService } from '../services/LocalLLMService'
|
||||||
|
import { getPremiumLLMService } from '../services/PremiumLLMService'
|
||||||
|
import { getVoiceModeService } from '../services/VoiceModeService'
|
||||||
import { configGet, configSet } from '../services/ConfigService'
|
import { configGet, configSet } from '../services/ConfigService'
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@d3ro/core/types'
|
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 {
|
export function registerLLMHandlers(): void {
|
||||||
// LLM 가용성 변경 시 렌더러에 상태 전파
|
// LLM 가용성 변경 시 렌더러에 상태 전파
|
||||||
const llm = getLocalLLMService()
|
const llm = getLocalLLMService()
|
||||||
llm.on('availability-changed', () => {
|
llm.on('availability-changed', () => {
|
||||||
const win = getMainWindow()
|
safeSendToRenderer(IPC_CHANNELS.LLM.STATUS_CHANGED, { status: llm.getStatus() })
|
||||||
if (win && !win.isDestroyed()) {
|
})
|
||||||
win.webContents.send(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 () => {
|
ipcMain.handle(IPC_CHANNELS.LLM.GET_STATUS, async () => {
|
||||||
return ipcSuccess(getLocalLLMService().getStatus())
|
return ipcSuccess(getLocalLLMService().getStatus())
|
||||||
|
|
@ -74,4 +94,19 @@ export function registerLLMHandlers(): void {
|
||||||
configSet('ollamaServerUrl', params.url)
|
configSet('ollamaServerUrl', params.url)
|
||||||
return ipcSuccess(undefined)
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,73 @@ class CloudSyncService extends EventEmitter {
|
||||||
return this._session?.user ?? null
|
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<string | null> {
|
||||||
|
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<string, unknown>): 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 {
|
getState(): CloudSyncState {
|
||||||
return {
|
return {
|
||||||
authenticated: this.isAuthenticated(),
|
authenticated: this.isAuthenticated(),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ const CONFIG_DEFAULTS: AppConfig = {
|
||||||
ttsSpeed: 1.0,
|
ttsSpeed: 1.0,
|
||||||
ollamaServerUrl: 'http://localhost:11434',
|
ollamaServerUrl: 'http://localhost:11434',
|
||||||
llmModelId: null,
|
llmModelId: null,
|
||||||
|
// Phase 3.2: 기본값은 'local' — 누구나 로그인 없이 로컬 Ollama로 쓸 수 있는
|
||||||
|
// 엔트리 전략. 사용자가 Settings에서 'premium'으로 전환 시 로그인 + 구독 필요.
|
||||||
|
llmBackend: 'local' as const,
|
||||||
defaultLLMAction: 'refine',
|
defaultLLMAction: 'refine',
|
||||||
dictationShortcut: {
|
dictationShortcut: {
|
||||||
keyCode: 0xa5, // Right Alt
|
keyCode: 0xa5, // Right Alt
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,24 @@ function generateMachineId(): string {
|
||||||
// ── 티어별 쿼터 한도 ──────────────────────────────────────
|
// ── 티어별 쿼터 한도 ──────────────────────────────────────
|
||||||
// -1 = 무제한, 값이 있으면 일일 한도.
|
// -1 = 무제한, 값이 있으면 일일 한도.
|
||||||
// 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용.
|
// 빅뱅 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<LicenseTier, Partial<Record<Feature, number>>> = {
|
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
|
||||||
free: {
|
free: {
|
||||||
// PREMIUM_LLM은 로그인한 free 사용자에게 하루 5회 맛보기
|
// Haiku만, 250/주간. 클라이언트에서는 대략적 일환산(~36/일)으로 표시.
|
||||||
[Feature.PREMIUM_LLM]: 5,
|
[Feature.PREMIUM_LLM]: 250,
|
||||||
},
|
},
|
||||||
pro: {
|
pro: {
|
||||||
// pro는 Premium LLM 무제한이지만 fair-use cap
|
// 모델별: Haiku 1500 + Sonnet 300 + Opus 50 = 합산 표시
|
||||||
[Feature.PREMIUM_LLM]: 500,
|
[Feature.PREMIUM_LLM]: 1850,
|
||||||
|
},
|
||||||
|
pro_plus: {
|
||||||
|
// Haiku 무제한 + Sonnet 1500 + Opus 300
|
||||||
},
|
},
|
||||||
pro_plus: {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { getLogger } from './LoggerService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types'
|
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types'
|
||||||
|
import { resolveSystemPrompt } from './llm-prompts'
|
||||||
|
|
||||||
const logger = getLogger('LocalLLMService')
|
const logger = getLogger('LocalLLMService')
|
||||||
|
|
||||||
|
|
@ -70,7 +71,6 @@ interface LocalLLMEvents {
|
||||||
// 시스템 프롬프트 (설계서 Phase 4 참조)
|
// 시스템 프롬프트 (설계서 Phase 4 참조)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// 시스템 프롬프트.
|
|
||||||
// 기본 권장 모델은 `gemma4:e4b` (non-reasoning). 기본값으로 thinking mode가
|
// 기본 권장 모델은 `gemma4:e4b` (non-reasoning). 기본값으로 thinking mode가
|
||||||
// 꺼져 있어 추가 토큰이 필요 없지만, 사용자가 수동으로 qwen3/deepseek-r1 등
|
// 꺼져 있어 추가 토큰이 필요 없지만, 사용자가 수동으로 qwen3/deepseek-r1 등
|
||||||
// reasoning 모델로 교체했을 때를 대비한 2중 방어:
|
// reasoning 모델로 교체했을 때를 대비한 2중 방어:
|
||||||
|
|
@ -79,30 +79,6 @@ interface LocalLLMEvents {
|
||||||
// (3) `stripReasoningBlocks()` 출력 가드
|
// (3) `stripReasoningBlocks()` 출력 가드
|
||||||
const NO_THINK = '/no_think'
|
const NO_THINK = '/no_think'
|
||||||
|
|
||||||
const SYSTEM_PROMPTS: Record<string, string> = {
|
|
||||||
refine: `${NO_THINK}
|
|
||||||
다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요.
|
|
||||||
원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요.
|
|
||||||
다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.`,
|
|
||||||
|
|
||||||
translate: `${NO_THINK}
|
|
||||||
다음 텍스트를 {{targetLanguage}}로 번역해주세요.
|
|
||||||
자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.`,
|
|
||||||
|
|
||||||
summarize: `${NO_THINK}
|
|
||||||
다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.
|
|
||||||
요약문만 출력하세요.`,
|
|
||||||
|
|
||||||
grammar: `${NO_THINK}
|
|
||||||
다음 텍스트의 문법 오류만 수정해주세요.
|
|
||||||
원래 의미와 톤을 유지하면서 문법 오류만 수정하세요.
|
|
||||||
수정된 텍스트만 출력하세요.`,
|
|
||||||
|
|
||||||
expand: `${NO_THINK}
|
|
||||||
다음 텍스트를 더 자세하고 풍부하게 확장해주세요.
|
|
||||||
확장된 텍스트만 출력하세요.`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reasoning model(qwen3, deepseek-r1 등)이 응답에 포함하는
|
* Reasoning model(qwen3, deepseek-r1 등)이 응답에 포함하는
|
||||||
* <think>...</think> 블록을 제거한다. /no_think 토큰을 무시하는
|
* <think>...</think> 블록을 제거한다. /no_think 토큰을 무시하는
|
||||||
|
|
@ -453,18 +429,8 @@ class LocalLLMService extends EventEmitter {
|
||||||
// LicenseService 미초기화 시 허용
|
// LicenseService 미초기화 시 허용
|
||||||
}
|
}
|
||||||
|
|
||||||
let systemPrompt: string
|
const basePrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
|
||||||
|
const systemPrompt = `${NO_THINK}\n${basePrompt}`
|
||||||
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 result = await this.generate(text, { systemPrompt })
|
const result = await this.generate(text, { systemPrompt })
|
||||||
const cleaned = stripReasoningBlocks(result.text)
|
const cleaned = stripReasoningBlocks(result.text)
|
||||||
|
|
|
||||||
284
apps/desktop/src/main/services/PremiumLLMService.ts
Normal file
284
apps/desktop/src/main/services/PremiumLLMService.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<string, string> {
|
||||||
|
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<ClaudeMessageResponse> {
|
||||||
|
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<string, unknown>)
|
||||||
|
|
||||||
|
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<K extends keyof PremiumLLMEvents>(event: K, listener: PremiumLLMEvents[K]): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof PremiumLLMEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<PremiumLLMEvents[K]>
|
||||||
|
): 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 }
|
||||||
|
|
@ -74,6 +74,11 @@ interface VoiceModeEvents {
|
||||||
}) => void
|
}) => void
|
||||||
'audio-level': (payload: { level: number }) => void
|
'audio-level': (payload: { level: number }) => void
|
||||||
error: (payload: { error: D3ROError; session: VoiceSession | null }) => 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 미초기화 시 무시
|
// VoiceCommandService 미초기화 시 무시
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM 후처리: none이면 스킵, 그 외에는 LLM 처리
|
// LLM 후처리: none이면 스킵, local backend인데 Ollama 미가용 시도 스킵.
|
||||||
|
// premium backend는 내부에서 local fallback을 시도하므로 스킵 안 함.
|
||||||
const llmAction = overrideAction ?? configGet('defaultLLMAction')
|
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)
|
this._completeSession(effectiveText)
|
||||||
} else {
|
} else {
|
||||||
await this._processWithLLM(effectiveText, overrideInstructionId)
|
await this._processWithLLM(effectiveText, overrideInstructionId)
|
||||||
|
|
@ -567,11 +577,69 @@ class VoiceModeService extends EventEmitter {
|
||||||
|
|
||||||
// ── LLM 후처리 ─────────────────────────────────────────
|
// ── 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> {
|
private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise<void> {
|
||||||
if (this._isInTerminalState()) return
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const llm = getLocalLLMService()
|
|
||||||
const action = configGet('defaultLLMAction')
|
const action = configGet('defaultLLMAction')
|
||||||
|
|
||||||
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
|
// 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 {
|
} else {
|
||||||
logger.info(`Processing with LLM (action: ${action})`)
|
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
|
if (this._isInTerminalState()) return
|
||||||
|
|
|
||||||
50
apps/desktop/src/main/services/llm-prompts.ts
Normal file
50
apps/desktop/src/main/services/llm-prompts.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 }
|
||||||
|
|
@ -299,7 +299,29 @@ const electronAPI = {
|
||||||
onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe =>
|
onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe =>
|
||||||
on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb),
|
on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb),
|
||||||
onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe =>
|
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 ────────────────────────────────────────────
|
// ── History ────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
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 DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
import HistoryIcon from '@mui/icons-material/History'
|
import HistoryIcon from '@mui/icons-material/History'
|
||||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
|
|
@ -62,6 +62,8 @@ export function AppLayout(): React.ReactElement {
|
||||||
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
||||||
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
||||||
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
||||||
|
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
|
||||||
|
const [fallbackMsg, setFallbackMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
|
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -89,9 +91,19 @@ export function AppLayout(): React.ReactElement {
|
||||||
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
||||||
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
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 () => {
|
return () => {
|
||||||
unsubTier()
|
unsubTier()
|
||||||
unsubUpgrade()
|
unsubUpgrade()
|
||||||
|
unsubFallback()
|
||||||
|
unsubUpgradeReq()
|
||||||
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -232,6 +244,18 @@ export function AppLayout(): React.ReactElement {
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
||||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||||
|
|
||||||
|
{/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */}
|
||||||
|
<Snackbar
|
||||||
|
open={fallbackMsg !== null}
|
||||||
|
autoHideDuration={8000}
|
||||||
|
onClose={() => setFallbackMsg(null)}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity="warning" variant="filled" onClose={() => setFallbackMsg(null)} sx={{ width: '100%' }}>
|
||||||
|
{fallbackMsg}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
Box,
|
Box,
|
||||||
TextField,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Divider,
|
|
||||||
CircularProgress,
|
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
|
@ -21,7 +18,7 @@ import {
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||||
import CancelIcon from '@mui/icons-material/Cancel'
|
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 { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||||
import { useI18n } from '@d3ro/i18n'
|
import { useI18n } from '@d3ro/i18n'
|
||||||
import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@d3ro/core/types'
|
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 {
|
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
|
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
|
||||||
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
|
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
|
||||||
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
||||||
const [keyInput, setKeyInput] = useState('')
|
|
||||||
const [activating, setActivating] = useState(false)
|
|
||||||
const [activateMessage, setActivateMessage] = useState<string | null>(null)
|
|
||||||
const [activateSuccess, setActivateSuccess] = useState(false)
|
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
window.electronAPI.license.getInfo().then((r) => {
|
window.electronAPI.license.getInfo().then((r) => {
|
||||||
|
|
@ -84,9 +67,6 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
loadData()
|
loadData()
|
||||||
setKeyInput('')
|
|
||||||
setActivateMessage(null)
|
|
||||||
setActivateSuccess(false)
|
|
||||||
}
|
}
|
||||||
}, [open, loadData])
|
}, [open, loadData])
|
||||||
|
|
||||||
|
|
@ -99,37 +79,13 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
||||||
return unsub
|
return unsub
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
const handleActivate = useCallback(async () => {
|
const currentTier = licenseInfo?.tier ?? 'free'
|
||||||
if (!keyInput.trim()) return
|
const isFree = currentTier === 'free'
|
||||||
setActivating(true)
|
const isPro = currentTier === 'pro'
|
||||||
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 handleDeactivate = useCallback(async () => {
|
const handleUpgrade = useCallback(() => {
|
||||||
await window.electronAPI.license.deactivate()
|
alert(t('license.paymentPending'))
|
||||||
setActivateMessage(t('license.deactivated'))
|
}, [t])
|
||||||
setActivateSuccess(false)
|
|
||||||
loadData()
|
|
||||||
}, [t, loadData])
|
|
||||||
|
|
||||||
const isFree = licenseInfo?.tier === 'free'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -164,9 +120,9 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3, overflow: 'auto' }}>
|
||||||
{/* ---- Current Tier ---- */}
|
{/* ---- Current Tier ---- */}
|
||||||
<MetalCard>
|
<MetalCard sx={{ overflow: 'visible' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
|
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
|
|
@ -178,92 +134,65 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
||||||
</Box>
|
</Box>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
|
|
||||||
{/* ---- Activate / Info ---- */}
|
{/* ---- Subscription Management ---- */}
|
||||||
<MetalCard>
|
<MetalCard sx={{ overflow: 'visible' }}>
|
||||||
{isFree ? (
|
|
||||||
// Free tier: show activation form
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<PhosphorText variant="meta">{t('license.activate')}</PhosphorText>
|
<PhosphorText variant="meta">{t('license.subscribe')}</PhosphorText>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<TextField
|
|
||||||
value={keyInput}
|
|
||||||
onChange={(e) => 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,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PhysicalButton
|
|
||||||
onClick={handleActivate}
|
|
||||||
disabled={activating || !keyInput.trim()}
|
|
||||||
sx={{ minWidth: 100 }}
|
|
||||||
>
|
|
||||||
{activating ? (
|
|
||||||
<CircularProgress size={16} sx={{ color: d3roPalette.accent.amber }} />
|
|
||||||
) : (
|
|
||||||
t('license.activate')
|
|
||||||
)}
|
|
||||||
</PhysicalButton>
|
|
||||||
</Box>
|
|
||||||
{activateMessage && (
|
|
||||||
<PhosphorText
|
|
||||||
variant="small"
|
|
||||||
sx={{ color: activateSuccess ? d3roPalette.tag.green : d3roPalette.tag.red }}
|
|
||||||
>
|
|
||||||
{activateMessage}
|
|
||||||
</PhosphorText>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
// Pro/Pro+: show license info
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
||||||
<PhosphorText variant="meta">{t('license.keyLabel')}</PhosphorText>
|
|
||||||
<ScreenPanel>
|
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
|
|
||||||
{licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'}
|
|
||||||
</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
</ScreenPanel>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
{isFree && (
|
||||||
<Box>
|
<>
|
||||||
<PhosphorText variant="label">{t('license.activatedAt')}</PhosphorText>
|
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led color="green" size={8} />
|
||||||
<PhosphorText variant="compact">
|
<PhosphorText variant="compact">
|
||||||
{formatDate(licenseInfo?.activatedAt ?? null)}
|
{t('license.upgrade')} — {t('license.proPlan')}
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
|
||||||
<PhosphorText variant="label">{t('license.machineId')}</PhosphorText>
|
|
||||||
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
|
|
||||||
{licenseInfo?.machineId?.slice(0, 12) ?? '-'}...
|
|
||||||
</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<PhysicalButton
|
|
||||||
onClick={handleDeactivate}
|
|
||||||
sx={{ alignSelf: 'flex-start', mt: 1, color: d3roPalette.tag.red }}
|
|
||||||
>
|
|
||||||
{t('license.deactivate')}
|
|
||||||
</PhysicalButton>
|
</PhysicalButton>
|
||||||
|
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led color="green" size={8} />
|
||||||
|
<PhosphorText variant="compact">
|
||||||
|
{t('license.upgrade')} — {t('license.proPlusPlan')}
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
</PhysicalButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPro && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Led color="green" size={8} pulse />
|
||||||
|
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.green }}>
|
||||||
|
{t('license.currentPlan')} — {t('license.proPlan')}
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led color="green" size={8} />
|
||||||
|
<PhosphorText variant="compact">
|
||||||
|
{t('license.upgrade')} — {t('license.proPlusPlan')}
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
</PhysicalButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTier === 'pro_plus' && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Led color="green" size={8} pulse />
|
||||||
|
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.purple }}>
|
||||||
|
{t('license.currentPlan')} — {t('license.proPlusPlan')}
|
||||||
|
</PhosphorText>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
|
|
||||||
{/* ---- Daily Usage ---- */}
|
{/* ---- Daily Usage ---- */}
|
||||||
{usageQuotas.length > 0 && (
|
{usageQuotas.length > 0 && (
|
||||||
<MetalCard>
|
<MetalCard sx={{ overflow: 'visible' }}>
|
||||||
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
|
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{usageQuotas.map((q) => (
|
{usageQuotas.map((q) => (
|
||||||
|
|
@ -309,7 +238,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
||||||
|
|
||||||
{/* ---- Tier Comparison ---- */}
|
{/* ---- Tier Comparison ---- */}
|
||||||
{tierComparison.length > 0 && (
|
{tierComparison.length > 0 && (
|
||||||
<MetalCard>
|
<MetalCard sx={{ overflow: 'visible' }}>
|
||||||
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
|
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
|
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
|
||||||
|
|
@ -333,7 +262,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
||||||
{tierComparison.map((row) => (
|
{tierComparison.map((row) => (
|
||||||
<TableRow key={row.feature}>
|
<TableRow key={row.feature}>
|
||||||
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
|
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
|
||||||
{row.featureLabel}
|
{t(row.featureLabel as Parameters<typeof t>[0])}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
|
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
|
||||||
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
|
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,39 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
{/* ── LLM 탭 ──────────────────────────────── */}
|
{/* ── LLM 탭 ──────────────────────────────── */}
|
||||||
<TabPanel value={activeTab} index={3}>
|
<TabPanel value={activeTab} index={3}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||||
|
{t('settings.llmBackend')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>{t('settings.llmBackend')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
label={t('settings.llmBackend')}
|
||||||
|
value={config.llmBackend ?? 'local'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value
|
||||||
|
updateConfig('llmBackend', next)
|
||||||
|
if (next === 'premium') {
|
||||||
|
// Premium 선택 시 항상 라이선스 모달 — 현재 티어/쿼터/업그레이드 안내
|
||||||
|
window.dispatchEvent(new Event('d3ro:open-license-modal'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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' }}>
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||||
{t('settings.ollamaServer')}
|
{t('settings.ollamaServer')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -729,6 +762,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import type {
|
||||||
|
|
||||||
describe('api-client types', () => {
|
describe('api-client types', () => {
|
||||||
it('SubscriptionTier 리터럴 union', () => {
|
it('SubscriptionTier 리터럴 union', () => {
|
||||||
const tiers: SubscriptionTier[] = ['free', 'pro', 'team']
|
const tiers: SubscriptionTier[] = ['free', 'pro', 'pro_plus']
|
||||||
expect(tiers).toHaveLength(3)
|
expect(tiers).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// packages/api-client/src/types.ts
|
// packages/api-client/src/types.ts
|
||||||
// Supabase DB row 타입 — V2-2 스키마와 동기화된 수동 정의.
|
// Supabase DB row 타입 — V2-2 스키마와 동기화된 수동 정의.
|
||||||
|
|
||||||
export type SubscriptionTier = 'free' | 'pro' | 'team'
|
export type SubscriptionTier = 'free' | 'pro' | 'pro_plus'
|
||||||
|
|
||||||
export type Profile = {
|
export type Profile = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -148,7 +148,11 @@ export type Subscription = {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
tier: SubscriptionTier
|
tier: SubscriptionTier
|
||||||
|
/** Phase 3.2: 베이스 쿼터 소진 시 차감되는 추가 크레딧 */
|
||||||
|
overage_credits: number
|
||||||
|
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
|
||||||
stripe_customer_id: string | null
|
stripe_customer_id: string | null
|
||||||
|
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
|
||||||
stripe_subscription_id: string | null
|
stripe_subscription_id: string | null
|
||||||
status: string | null
|
status: string | null
|
||||||
current_period_start: string | null
|
current_period_start: string | null
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,19 @@ export const IPC_CHANNELS = {
|
||||||
GET_SERVER_URL: 'llm:getServerUrl',
|
GET_SERVER_URL: 'llm:getServerUrl',
|
||||||
SET_SERVER_URL: 'llm:setServerUrl',
|
SET_SERVER_URL: 'llm:setServerUrl',
|
||||||
PULL_MODEL: 'llm:pullModel',
|
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
|
// Main → Renderer events
|
||||||
STATUS_CHANGED: 'llm:statusChanged',
|
STATUS_CHANGED: 'llm:statusChanged',
|
||||||
PROCESS_PROGRESS: 'llm:processProgress',
|
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: {
|
HOTKEY: {
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,12 @@ export interface AppConfig {
|
||||||
ttsSpeed: number
|
ttsSpeed: number
|
||||||
ollamaServerUrl: string
|
ollamaServerUrl: string
|
||||||
llmModelId: string | null
|
llmModelId: string | null
|
||||||
|
/**
|
||||||
|
* Phase 3.2: LLM 백엔드 선택.
|
||||||
|
* 'local' — LocalLLMService (Ollama, 기본값, 무료)
|
||||||
|
* 'premium' — PremiumLLMService (Supabase llm-proxy → Claude, 로그인+구독 필요)
|
||||||
|
*/
|
||||||
|
llmBackend: 'local' | 'premium'
|
||||||
defaultLLMAction: LLMAction
|
defaultLLMAction: LLMAction
|
||||||
dictationShortcut: HotkeyBinding
|
dictationShortcut: HotkeyBinding
|
||||||
handsFreeShortcut: HotkeyBinding
|
handsFreeShortcut: HotkeyBinding
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,11 @@
|
||||||
"settings.ollamaServer": "Ollama Server",
|
"settings.ollamaServer": "Ollama Server",
|
||||||
"settings.ollamaUrl": "Ollama Server URL",
|
"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.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.llmModel": "LLM Model",
|
||||||
"settings.postProcess": "Voice Post-Processing",
|
"settings.postProcess": "Voice Post-Processing",
|
||||||
"settings.defaultAction": "Default Post-Processing Command",
|
"settings.defaultAction": "Default Post-Processing Command",
|
||||||
|
|
@ -276,7 +281,12 @@
|
||||||
"license.machineId": "Machine ID",
|
"license.machineId": "Machine ID",
|
||||||
"license.activatedAt": "Activated At",
|
"license.activatedAt": "Activated At",
|
||||||
"license.manageLicense": "Manage License",
|
"license.manageLicense": "Manage License",
|
||||||
|
"license.subscribe": "Subscribe",
|
||||||
"license.upgrade": "Upgrade",
|
"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.upgradeTitle": "Upgrade to Pro",
|
||||||
"license.upgradeDesc": "Unlock all features",
|
"license.upgradeDesc": "Unlock all features",
|
||||||
"license.quotaUsed": "{{used}}/{{limit}} used",
|
"license.quotaUsed": "{{used}}/{{limit}} used",
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,11 @@
|
||||||
"settings.ollamaServer": "Ollama 서버",
|
"settings.ollamaServer": "Ollama 서버",
|
||||||
"settings.ollamaUrl": "Ollama 서버 URL",
|
"settings.ollamaUrl": "Ollama 서버 URL",
|
||||||
"settings.ollamaHint": "Ollama가 실행 중이면 자동으로 연결됩니다. 모델은 Ollama에서 직접 pull하세요 (예: ollama pull gemma4:e4b).",
|
"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.llmModel": "LLM 모델",
|
||||||
"settings.postProcess": "음성 후처리",
|
"settings.postProcess": "음성 후처리",
|
||||||
"settings.defaultAction": "기본 후처리 명령어",
|
"settings.defaultAction": "기본 후처리 명령어",
|
||||||
|
|
@ -277,7 +282,12 @@
|
||||||
"license.machineId": "기기 ID",
|
"license.machineId": "기기 ID",
|
||||||
"license.activatedAt": "활성화 일시",
|
"license.activatedAt": "활성화 일시",
|
||||||
"license.manageLicense": "라이선스 관리",
|
"license.manageLicense": "라이선스 관리",
|
||||||
|
"license.subscribe": "구독하기",
|
||||||
"license.upgrade": "업그레이드",
|
"license.upgrade": "업그레이드",
|
||||||
|
"license.currentPlan": "현재 구독 중",
|
||||||
|
"license.proPlan": "Pro — ₩9,900/월",
|
||||||
|
"license.proPlusPlan": "Pro+ — ₩29,900/월",
|
||||||
|
"license.paymentPending": "결제 연동 준비 중 (Payple)",
|
||||||
"license.upgradeTitle": "Pro로 업그레이드",
|
"license.upgradeTitle": "Pro로 업그레이드",
|
||||||
"license.upgradeDesc": "모든 기능을 잠금 해제하세요",
|
"license.upgradeDesc": "모든 기능을 잠금 해제하세요",
|
||||||
"license.quotaUsed": "{{used}}/{{limit}} 사용",
|
"license.quotaUsed": "{{used}}/{{limit}} 사용",
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ inspector_port = 8083
|
||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
||||||
[functions.llm-proxy]
|
[functions.llm-proxy]
|
||||||
verify_jwt = true
|
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.stripe-checkout]
|
[functions.stripe-checkout]
|
||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,124 @@
|
||||||
// server/supabase/functions/_shared/quota.ts
|
// server/supabase/functions/_shared/quota.ts
|
||||||
// 티어별 기능 쿼터 확인 + 증가
|
// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리
|
||||||
|
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
|
||||||
|
|
||||||
// @ts-expect-error — Deno 런타임 import
|
// @ts-expect-error — Deno 런타임 import
|
||||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
|
||||||
|
|
||||||
export type Tier = 'free' | 'pro' | 'team'
|
export type Tier = 'free' | 'pro' | 'pro_plus'
|
||||||
export type Feature = 'stt_transcribe' | 'llm_process'
|
|
||||||
|
|
||||||
/** 일일 쿼터 정책 (-1 = 무제한) */
|
/** 쿼터 추적 키 — 모델별 분리 */
|
||||||
const DAILY_QUOTA: Record<Tier, Record<Feature, number>> = {
|
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<Tier, Record<QuotaFeature, ModelQuota>> = {
|
||||||
free: {
|
free: {
|
||||||
stt_transcribe: 50,
|
stt_transcribe: { limit: 250, period: 'weekly' },
|
||||||
llm_process: 50
|
llm_haiku: { limit: 250, period: 'weekly' },
|
||||||
|
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
|
||||||
|
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
|
||||||
},
|
},
|
||||||
pro: {
|
pro: {
|
||||||
stt_transcribe: -1,
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
llm_process: -1
|
llm_haiku: { limit: 1500, period: 'daily' },
|
||||||
|
llm_sonnet: { limit: 300, period: 'daily' },
|
||||||
|
llm_opus: { limit: 50, period: 'daily' },
|
||||||
},
|
},
|
||||||
team: {
|
pro_plus: {
|
||||||
stt_transcribe: -1,
|
stt_transcribe: { limit: -1, period: 'daily' },
|
||||||
llm_process: -1
|
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 {
|
export interface QuotaCheck {
|
||||||
allowed: boolean
|
allowed: boolean
|
||||||
current: number
|
current: number
|
||||||
limit: number
|
limit: number
|
||||||
|
period: QuotaPeriod
|
||||||
tier: Tier
|
tier: Tier
|
||||||
|
overageCredits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaConsumeResult {
|
||||||
|
allowed: boolean
|
||||||
|
current: number
|
||||||
|
limit: number
|
||||||
|
overageCredits: number
|
||||||
|
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 유저의 오늘 사용량을 확인하고 쿼터 초과 여부를 반환.
|
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
|
||||||
* 실제 증가는 performQuotaConsume 호출 시 수행.
|
* weekly인 경우 최근 7일 daily_usage를 합산.
|
||||||
*/
|
*/
|
||||||
export async function checkQuota(
|
export async function checkQuota(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: Feature,
|
feature: QuotaFeature,
|
||||||
serviceRoleClient: ReturnType<typeof createClient>
|
serviceRoleClient: ReturnType<typeof createClient>,
|
||||||
): Promise<QuotaCheck> {
|
): Promise<QuotaCheck> {
|
||||||
// 티어 조회
|
// 티어 + overage 조회
|
||||||
const { data: sub } = await serviceRoleClient
|
const { data: sub } = await serviceRoleClient
|
||||||
.from('subscriptions')
|
.from('subscriptions')
|
||||||
.select('tier')
|
.select('tier, overage_credits')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
const tier: Tier = (sub?.tier as Tier) ?? 'free'
|
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) {
|
// 사용불가 (limit=0)
|
||||||
return { allowed: true, current: 0, limit, tier }
|
if (policy.limit === 0) {
|
||||||
|
return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오늘 사용량 조회
|
// 무제한
|
||||||
|
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 today = new Date().toISOString().slice(0, 10)
|
||||||
const { data: usage } = await serviceRoleClient
|
const { data: usage } = await serviceRoleClient
|
||||||
.from('daily_usage')
|
.from('daily_usage')
|
||||||
|
|
@ -62,36 +127,56 @@ export async function checkQuota(
|
||||||
.eq('date', today)
|
.eq('date', today)
|
||||||
.eq('feature', feature)
|
.eq('feature', feature)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
current = (usage?.count as number) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
const current = (usage?.count as number) ?? 0
|
|
||||||
return {
|
return {
|
||||||
allowed: current < limit,
|
allowed: current < policy.limit || overageCredits > 0,
|
||||||
current,
|
current,
|
||||||
limit,
|
limit: policy.limit,
|
||||||
tier
|
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(
|
export async function consumeQuota(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: Feature,
|
feature: QuotaFeature,
|
||||||
serviceRoleClient: ReturnType<typeof createClient>,
|
serviceRoleClient: ReturnType<typeof createClient>,
|
||||||
amount: number = 1
|
baseLimit: number,
|
||||||
): Promise<number> {
|
): Promise<QuotaConsumeResult> {
|
||||||
const { data, error } = await serviceRoleClient.rpc('increment_daily_usage', {
|
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
|
||||||
p_user_id: userId,
|
p_user_id: userId,
|
||||||
p_feature: feature,
|
p_feature: feature,
|
||||||
p_amount: amount
|
p_base_limit: baseLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
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<typeof createClient> {
|
||||||
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
// @ts-expect-error — Deno.env는 Deno 런타임 전역
|
||||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||||
return createClient(url, serviceKey, {
|
return createClient(url, serviceKey, {
|
||||||
auth: { persistSession: false, autoRefreshToken: false }
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
// server/supabase/functions/llm-proxy/index.ts
|
// server/supabase/functions/llm-proxy/index.ts
|
||||||
// Anthropic Claude Messages API 프록시.
|
// Anthropic Claude Messages API 프록시.
|
||||||
|
// Phase 3.2: 모델별 쿼터 (Haiku/Sonnet/Opus × Free/Pro/Pro+)
|
||||||
// 요청: application/json { messages, system?, max_tokens?, model? }
|
// 요청: application/json { messages, system?, max_tokens?, model? }
|
||||||
// 응답: JSON (non-stream) 또는 SSE (stream=true)
|
// 응답: JSON (non-stream) 또는 SSE (stream=true)
|
||||||
|
|
||||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.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 {
|
interface LlmRequest {
|
||||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
|
|
@ -15,17 +23,17 @@ interface LlmRequest {
|
||||||
stream?: boolean
|
stream?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 티어별 허용 모델 */
|
/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus */
|
||||||
const TIER_MODELS: Record<Tier, string[]> = {
|
const TIER_MODELS: Record<Tier, string[]> = {
|
||||||
free: ['claude-haiku-4-5-20251001'],
|
free: ['claude-haiku-4-5-20251001'],
|
||||||
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'],
|
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
|
||||||
team: ['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<Tier, string> = {
|
const DEFAULT_MODEL: Record<Tier, string> = {
|
||||||
free: 'claude-haiku-4-5-20251001',
|
free: 'claude-haiku-4-5-20251001',
|
||||||
pro: 'claude-sonnet-4-6',
|
pro: 'claude-sonnet-4-6',
|
||||||
team: 'claude-sonnet-4-6'
|
pro_plus: 'claude-sonnet-4-6',
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error — Deno 런타임 전역
|
// @ts-expect-error — Deno 런타임 전역
|
||||||
|
|
@ -36,45 +44,75 @@ Deno.serve(async (req: Request) => {
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await requireUser(req)
|
const user = await requireUser(req)
|
||||||
const serviceClient = createServiceRoleClient()
|
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
|
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]
|
const requestedModel = body.model ?? DEFAULT_MODEL[tier]
|
||||||
if (!TIER_MODELS[quota.tier].includes(requestedModel)) {
|
if (!TIER_MODELS[tier].includes(requestedModel)) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: 'model_not_allowed',
|
error: 'model_not_allowed',
|
||||||
tier: quota.tier,
|
tier,
|
||||||
requested: requestedModel,
|
requested: requestedModel,
|
||||||
allowed: TIER_MODELS[quota.tier]
|
allowed: TIER_MODELS[tier],
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
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
|
// @ts-expect-error — Deno.env
|
||||||
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
|
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
|
||||||
|
|
||||||
await consumeQuota(user.id, 'llm_process', serviceClient, 1)
|
|
||||||
|
|
||||||
if (!anthropicKey) {
|
if (!anthropicKey) {
|
||||||
// Placeholder 응답 (키 미설정 시)
|
// Placeholder 응답 (키 미설정 시)
|
||||||
if (body.stream) {
|
if (body.stream) {
|
||||||
// 스트리밍 placeholder — SSE로 "설정되지 않음" 메시지 전송
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
|
|
@ -95,13 +130,13 @@ Deno.serve(async (req: Request) => {
|
||||||
for (const ch of msg) {
|
for (const ch of msg) {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(
|
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.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||||
controller.close()
|
controller.close()
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
@ -109,8 +144,8 @@ Deno.serve(async (req: Request) => {
|
||||||
...corsHeaders,
|
...corsHeaders,
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive'
|
Connection: 'keep-alive',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,16 +157,16 @@ Deno.serve(async (req: Request) => {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]'
|
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
stop_reason: 'end_turn',
|
stop_reason: 'end_turn',
|
||||||
usage: { input_tokens: 0, output_tokens: 0 }
|
usage: { input_tokens: 0, output_tokens: 0 },
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,15 +176,15 @@ Deno.serve(async (req: Request) => {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-api-key': anthropicKey,
|
'x-api-key': anthropicKey,
|
||||||
'anthropic-version': '2023-06-01'
|
'anthropic-version': '2023-06-01',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: requestedModel,
|
model: requestedModel,
|
||||||
max_tokens: body.max_tokens ?? 2048,
|
max_tokens: body.max_tokens ?? 2048,
|
||||||
system: body.system,
|
system: body.system,
|
||||||
messages: body.messages,
|
messages: body.messages,
|
||||||
stream: body.stream ?? false
|
stream: body.stream ?? false,
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!anthropicResp.ok) {
|
if (!anthropicResp.ok) {
|
||||||
|
|
@ -158,22 +193,21 @@ Deno.serve(async (req: Request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.stream && anthropicResp.body) {
|
if (body.stream && anthropicResp.body) {
|
||||||
// SSE 스트림을 그대로 전달
|
|
||||||
return new Response(anthropicResp.body, {
|
return new Response(anthropicResp.body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
...corsHeaders,
|
...corsHeaders,
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Connection: 'keep-alive'
|
Connection: 'keep-alive',
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await anthropicResp.json()
|
const data = await anthropicResp.json()
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err && typeof err === 'object' && 'status' in err && 'message' in 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'
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) 쿼터 소비
|
// 5) 쿼터 소비
|
||||||
await consumeQuota(user.id, 'stt_transcribe', serviceClient, 1)
|
await consumeQuota(user.id, 'stt_transcribe', serviceClient, quota.limit)
|
||||||
|
|
||||||
return new Response(JSON.stringify(placeholder), {
|
return new Response(JSON.stringify(placeholder), {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue