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:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue