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:
윤찬 2026-04-12 18:28:02 +09:00
parent d397bcbf57
commit 6e52c18e5b
23 changed files with 1111 additions and 311 deletions

View file

@ -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)
})
}

View file

@ -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<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 {
return {
authenticated: this.isAuthenticated(),

View file

@ -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

View file

@ -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<LicenseTier, Partial<Record<Feature, number>>> = {
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: {},
}
// ── 기능별 최소 필요 티어 ──────────────────────────────────

View file

@ -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<string, string> = {
refine: `${NO_THINK}
.
, .
. .`,
translate: `${NO_THINK}
{{targetLanguage}} .
. .`,
summarize: `${NO_THINK}
3 .
.`,
grammar: `${NO_THINK}
.
.
.`,
expand: `${NO_THINK}
.
.`
}
/**
* Reasoning model(qwen3, deepseek-r1 )
* <think>...</think> . /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)

View 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 }

View file

@ -74,6 +74,11 @@ interface VoiceModeEvents {
}) => void
'audio-level': (payload: { level: number }) => void
error: (payload: { error: D3ROError; session: VoiceSession | null }) => void
/**
* Phase 3.2: Premium LLM Local로 fallback된 emit.
* renderer에서 Snackbar .
*/
'premium-llm-fallback': (payload: { reason: string }) => void
}
// ============================================================
@ -547,9 +552,14 @@ class VoiceModeService extends EventEmitter {
// VoiceCommandService 미초기화 시 무시
}
// LLM 후처리: none이면 스킵, 그 외에는 LLM 처리
// LLM 후처리: none이면 스킵, local backend인데 Ollama 미가용 시도 스킵.
// premium backend는 내부에서 local fallback을 시도하므로 스킵 안 함.
const llmAction = overrideAction ?? configGet('defaultLLMAction')
if (llmAction === 'none' || !getLocalLLMService().isAvailable()) {
const backend = configGet('llmBackend')
const skipLLM =
llmAction === 'none' ||
(backend === 'local' && !getLocalLLMService().isAvailable())
if (skipLLM) {
this._completeSession(effectiveText)
} else {
await this._processWithLLM(effectiveText, overrideInstructionId)
@ -567,11 +577,69 @@ class VoiceModeService extends EventEmitter {
// ── LLM 후처리 ─────────────────────────────────────────
/**
* Phase 3.2: llmBackend config + PremiumLLMService
* local/premium . premium local로
* silent fallback + 'premium-llm-fallback' emit.
*
* 반환: 실제 processText + .
*/
private async _getLLMProcessor(): Promise<{
service: { processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string> }
backend: 'local' | 'premium'
}> {
const backend = configGet('llmBackend')
if (backend === 'premium') {
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const premium = getPremiumLLMService()
if (premium.isAvailable()) {
return { service: premium, backend: 'premium' }
}
this._emitPremiumFallback('Premium 사용 불가 — 로그인 또는 네트워크 확인')
} catch (err) {
this._emitPremiumFallback(
`Premium 초기화 실패: ${err instanceof Error ? err.message : String(err)}`
)
}
}
return { service: getLocalLLMService(), backend: 'local' }
}
private _emitPremiumFallback(reason: string): void {
logger.warn(`Premium LLM fallback → local: ${reason}`)
this.emit('premium-llm-fallback', { reason })
}
/**
* Phase 3.2: backend + Premium Local fallback을
* processText . backend .
*/
private async _runProcessorWithFallback(
text: string,
action: LLMAction,
targetLanguage?: string,
customPrompt?: string,
): Promise<string> {
const processor = await this._getLLMProcessor()
try {
return await processor.service.processText(text, action, targetLanguage, customPrompt)
} catch (err) {
if (processor.backend === 'premium') {
this._emitPremiumFallback(
`Premium 호출 실패: ${err instanceof Error ? err.message : String(err)}`
)
// Local로 재시도
return getLocalLLMService().processText(text, action, targetLanguage, customPrompt)
}
throw err
}
}
private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise<void> {
if (this._isInTerminalState()) return
try {
const llm = getLocalLLMService()
const action = configGet('defaultLLMAction')
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
@ -620,10 +688,10 @@ class VoiceModeService extends EventEmitter {
}
}
processedText = await llm.processText(customPrompt, 'custom')
processedText = await this._runProcessorWithFallback(customPrompt, 'custom')
} else {
logger.info(`Processing with LLM (action: ${action})`)
processedText = await llm.processText(contextPrefix + transcribedText, action)
processedText = await this._runProcessorWithFallback(contextPrefix + transcribedText, action)
}
if (this._isInTerminalState()) return

View 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 }

View file

@ -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 ────────────────────────────────────────────

View file

@ -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<LicenseTier>('free')
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
const [fallbackMsg, setFallbackMsg] = useState<string | null>(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 {
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(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>
)
}

View file

@ -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<LicenseInfo | null>(null)
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
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(() => {
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 (
<Dialog
@ -164,9 +120,9 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
</IconButton>
</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 ---- */}
<MetalCard>
<MetalCard sx={{ overflow: 'visible' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
<Box sx={{ flex: 1 }}>
@ -178,92 +134,65 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
</Box>
</MetalCard>
{/* ---- Activate / Info ---- */}
<MetalCard>
{isFree ? (
// Free tier: show activation form
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<PhosphorText variant="meta">{t('license.activate')}</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')
)}
{/* ---- Subscription Management ---- */}
<MetalCard sx={{ overflow: 'visible' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<PhosphorText variant="meta">{t('license.subscribe')}</PhosphorText>
{isFree && (
<>
<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.proPlan')}
</PhosphorText>
</Box>
</PhysicalButton>
</Box>
{activateMessage && (
<PhosphorText
variant="small"
sx={{ color: activateSuccess ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{activateMessage}
<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>
) : (
// 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 }}>
<Box>
<PhosphorText variant="label">{t('license.activatedAt')}</PhosphorText>
<PhosphorText variant="compact">
{formatDate(licenseInfo?.activatedAt ?? null)}
</PhosphorText>
</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>
</Box>
)}
)}
</Box>
</MetalCard>
{/* ---- Daily Usage ---- */}
{usageQuotas.length > 0 && (
<MetalCard>
<MetalCard sx={{ overflow: 'visible' }}>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{usageQuotas.map((q) => (
@ -309,7 +238,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
{/* ---- Tier Comparison ---- */}
{tierComparison.length > 0 && (
<MetalCard>
<MetalCard sx={{ overflow: 'visible' }}>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
<TableContainer>
<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) => (
<TableRow key={row.feature}>
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
{row.featureLabel}
{t(row.featureLabel as Parameters<typeof t>[0])}
</TableCell>
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>

View file

@ -693,43 +693,78 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.ollamaServer')}
</Typography>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
/>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{t('settings.ollamaHint')}
</Typography>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.llmModel')}
{t('settings.llmBackend')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.llmModel')}</InputLabel>
<InputLabel>{t('settings.llmBackend')}</InputLabel>
<Select
label={t('settings.llmModel')}
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
label={t('settings.llmBackend')}
value={config.llmBackend ?? 'local'}
onChange={(e) => {
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
const next = e.target.value
updateConfig('llmBackend', next)
if (next === 'premium') {
// Premium 선택 시 항상 라이선스 모달 — 현재 티어/쿼터/업그레이드 안내
window.dispatchEvent(new Event('d3ro:open-license-modal'))
}
}}
>
{llmModels.map((model) => (
<MenuItem key={model.id} value={model.id}>
{model.name} ({model.parameterSize})
</MenuItem>
))}
<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' }}>
{t('settings.ollamaServer')}
</Typography>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
/>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{t('settings.ollamaHint')}
</Typography>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.llmModel')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.llmModel')}</InputLabel>
<Select
label={t('settings.llmModel')}
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
onChange={(e) => {
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
}}
>
{llmModels.map((model) => (
<MenuItem key={model.id} value={model.id}>
{model.name} ({model.parameterSize})
</MenuItem>
))}
</Select>
</FormControl>
</>
)}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>