Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템

Phase 10 킬러 피처:
- MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB)
- VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종
- ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트
- ChainService: LLM 명령어 순차 실행 파이프라인
- CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백

VoiceModeService 파이프라인 통합:
- 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입

시스템 오디오 캡처:
- setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지)
- electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현

Phase 11 수익화:
- LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API
- Feature Gate: requireFeature/checkFeature/consumeFeature
- 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage)
- LicenseModal, ProBadge, UpgradePromptModal UI

디자인 보강:
- d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템
- ScreenPanel, ButtonGroup DS 컴포넌트 신규
- PhosphorText 4→13종 변형, MetalDial conic-gradient 광택
- 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard

기타:
- 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings)
- StatusBar 자막 LED + 효과음, 자막 로딩 UI
- LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged)
- 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
Yun Chan 2026-04-05 21:36:09 +09:00
parent 36d77ca224
commit a31f96bbb8
97 changed files with 11853 additions and 1143 deletions

View file

@ -28,6 +28,7 @@ import {
sendAudioLevelToTip,
showResultPopup,
} from '../windows/WindowManager'
import type { ScreenContext } from '@shared/types'
const logger = getLogger('VoiceModeService')
@ -45,6 +46,7 @@ interface VoiceSession {
transcription: string
processedText: string | null
accidentalPress: boolean
screenContext: ScreenContext | null
}
interface VoiceAction {
@ -147,11 +149,18 @@ class VoiceModeService extends EventEmitter {
const hotkey = getHotkeyService()
this._hotkeyPressHandler = (payload) => {
// Phase 10.1: caption 핫키는 VoiceModeService가 아닌 CaptionService로 라우팅
if (payload.config.id === 'voice-caption') {
this._toggleCaption()
return
}
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
this._hotkeyReleaseHandler = (payload) => {
// caption 핫키의 release는 무시 (토글 방식)
if (payload.config.id === 'voice-caption') return
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
@ -177,6 +186,49 @@ class VoiceModeService extends EventEmitter {
return
}
// Phase 11: 라이센스 쿼터 체크
try {
const { getLicenseService } = await import('./LicenseService')
const { Feature } = await import('@shared/types')
const license = getLicenseService()
const access = license.canUse(Feature.DICTATION)
if (!access.allowed) {
license.promptUpgrade(Feature.DICTATION, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required')
logger.warn(`Dictation blocked: ${access.reason}`)
return
}
license.consumeQuota(Feature.DICTATION)
} catch {
// LicenseService 미초기화 시 허용 (graceful)
}
// Phase 10.1: 자막 모드 활성 중이면 dictation 세션 시작 불가 (AudioCaptureService 공유)
try {
const { getCaptionService } = await import('./CaptionService')
const captionState = getCaptionService().getState()
if (captionState === 'active' || captionState === 'starting') {
logger.warn('Cannot start dictation: caption mode active')
return
}
} catch {
// CaptionService 미초기화 시 무시
}
// Phase 10.2: 스크린 컨텍스트 캡처 (녹음 시작 전, 활성 앱 정보 보존)
let screenContext: ScreenContext | null = null
try {
const { getScreenContextService } = await import('./ScreenContextService')
const ctx = getScreenContextService()
if (ctx.isEnabled()) {
const captureSelected = configGet('screenContextEnabled' as keyof import('@shared/types').AppConfig) as unknown as boolean
const result = await ctx.captureContext(captureSelected)
screenContext = result.context
logger.info(`Screen context captured: ${screenContext.appName ?? 'unknown'}`)
}
} catch {
// 컨텍스트 캡처 실패 시 무시 — 핵심 기능 아님
}
// 세션 생성
this._session = {
id: randomUUID(),
@ -187,7 +239,8 @@ class VoiceModeService extends EventEmitter {
audioBufferDurationMs: 0,
transcription: '',
processedText: null,
accidentalPress: false
accidentalPress: false,
screenContext,
}
this._errorEmitted = false
@ -440,12 +493,32 @@ class VoiceModeService extends EventEmitter {
this.emit('transcription-update', { text: result.text, isFinal: true })
// Phase 10.5: 음성 단축키 — 키워드 매칭으로 LLM 명령어 자동 선택
let effectiveText = result.text
let overrideAction: string | null = null
let overrideInstructionId: string | null = null
try {
const { getVoiceCommandService } = await import('./VoiceCommandService')
const vcSvc = getVoiceCommandService()
if (vcSvc.isEnabled()) {
const match = vcSvc.match(result.text)
if (match.matched && match.instructionId) {
effectiveText = match.cleanedText
overrideAction = 'custom'
overrideInstructionId = match.instructionId
logger.info(`Voice command matched: keyword="${match.matchedKeyword}", instruction=${match.instructionId}`)
}
}
} catch {
// VoiceCommandService 미초기화 시 무시
}
// LLM 후처리: none이면 스킵, 그 외에는 LLM 처리
const llmAction = configGet('defaultLLMAction')
const llmAction = overrideAction ?? configGet('defaultLLMAction')
if (llmAction === 'none' || !getLocalLLMService().isAvailable()) {
this._completeSession(result.text)
this._completeSession(effectiveText)
} else {
await this._processWithLLM(result.text)
await this._processWithLLM(effectiveText, overrideInstructionId)
}
} catch (error) {
if (this._isInTerminalState()) return
@ -460,26 +533,55 @@ class VoiceModeService extends EventEmitter {
// ── LLM 후처리 ─────────────────────────────────────────
private async _processWithLLM(transcribedText: string): Promise<void> {
private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise<void> {
if (this._isInTerminalState()) return
// RECOGNIZING 상태 유지 (UI에서 thinking으로 표시됨)
try {
const llm = getLocalLLMService()
const action = configGet('defaultLLMAction')
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
let contextPrefix = ''
if (this._session?.screenContext) {
try {
const { getScreenContextService } = await import('./ScreenContextService')
contextPrefix = getScreenContextService().buildContextPrompt(this._session.screenContext)
} catch {
// 무시
}
}
// Phase 10.4: 체인 모드 처리
if (action === 'chain') {
try {
const { getChainService } = await import('./ChainService')
const activeChainId = configGet('activeChainId' as keyof import('@shared/types').AppConfig) as unknown as string
if (activeChainId) {
const chainResult = await getChainService().execute(activeChainId, contextPrefix + transcribedText)
if (this._isInTerminalState()) return
if (this._session) this._session.processedText = chainResult.finalText
this._completeSession(chainResult.finalText)
return
}
} catch (error) {
logger.warn(`Chain execution failed, falling back: ${error instanceof Error ? error.message : String(error)}`)
}
}
let processedText: string
if (action === 'custom') {
// 활성 명령어의 프롬프트를 사용
const activeId = configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string
let customPrompt = transcribedText
// 음성 단축키 오버라이드 또는 활성 명령어
const effectiveInstructionId = overrideInstructionId
?? (configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string)
if (activeId) {
if (action === 'custom' || overrideInstructionId) {
let customPrompt = contextPrefix + transcribedText
if (effectiveInstructionId) {
const { getCustomInstructionService } = await import('./CustomInstructionService')
const instruction = getCustomInstructionService().getById(activeId)
const instruction = getCustomInstructionService().getById(effectiveInstructionId)
if (instruction) {
customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, transcribedText)
customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, contextPrefix + transcribedText)
logger.info(`Using custom instruction: "${instruction.name}"`)
}
}
@ -487,7 +589,7 @@ class VoiceModeService extends EventEmitter {
processedText = await llm.processText(customPrompt, 'custom')
} else {
logger.info(`Processing with LLM (action: ${action})`)
processedText = await llm.processText(transcribedText, action)
processedText = await llm.processText(contextPrefix + transcribedText, action)
}
if (this._isInTerminalState()) return
@ -500,7 +602,6 @@ class VoiceModeService extends EventEmitter {
} catch (error) {
if (this._isInTerminalState()) return
logger.warn(`LLM processing failed, using original text: ${error instanceof Error ? error.message : String(error)}`)
// LLM 실패 시 원본 텍스트로 폴백
this._completeSession(transcribedText)
}
}
@ -702,6 +803,32 @@ class VoiceModeService extends EventEmitter {
return 'dictation'
}
// ── 자막 모드 토글 (Phase 10.1) ─────────────────────────
private async _toggleCaption(): Promise<void> {
try {
const { getCaptionService } = await import('./CaptionService')
const caption = getCaptionService()
const state = caption.getState()
if (state === 'active' || state === 'starting') {
// 자막 활성 중 → 정지
await caption.stop()
logger.info('Caption stopped via hotkey')
} else {
// dictation 세션이 활성이면 자막 시작 불가 (상호 배제)
if (this._session && !this._isInTerminalState()) {
logger.warn('Cannot start caption: dictation session active')
return
}
await caption.start()
logger.info('Caption started via hotkey')
}
} catch (error) {
logger.error(`Caption toggle failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
// ── 종료 ───────────────────────────────────────────────
dispose(): void {