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
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
}
|
||||
|
||||
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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
|
||||
'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
|
||||
|
|
|
|||
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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue