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

@ -0,0 +1,342 @@
// src/main/services/ScreenContextService.ts
// 활성 윈도우 정보 + 선택된 텍스트를 캡처하여 LLM 프롬프트에 컨텍스트로 제공한다.
// Phase 10.2 스크린 컨텍스트. Speakly ContextService 참조.
import { clipboard } from 'electron'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import type { ScreenContext, CaptureContextResult } from '@shared/types'
const logger = getLogger('screen-context')
// ============================================================
// nut-js 타입 (lazy import)
// ============================================================
interface NutKeyboard {
pressKey: (...keys: number[]) => Promise<void>
releaseKey: (...keys: number[]) => Promise<void>
Key: Record<string, number>
}
// ============================================================
// 클립보드 스냅샷 (TextInsertService와 동일 패턴)
// ============================================================
interface ClipboardSnapshot {
text: string | null
html: string | null
image: Electron.NativeImage | null
rtf: string | null
hasContent: boolean
}
// ============================================================
// ScreenContextService
// ============================================================
class ScreenContextService {
private _nutKeyboard: NutKeyboard | null = null
private _nutLoadPromise: Promise<void> | null = null
/**
* nut-js lazy dynamic import (TextInsertService ).
*/
private async _ensureNut(): Promise<NutKeyboard> {
if (this._nutKeyboard) return this._nutKeyboard
if (!this._nutLoadPromise) {
this._nutLoadPromise = (async () => {
try {
const nut = await import('@nut-tree-fork/nut-js')
this._nutKeyboard = {
pressKey: nut.keyboard.pressKey.bind(nut.keyboard),
releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard),
Key: nut.Key,
}
logger.info('nut-js loaded for screen context')
} catch (error) {
logger.error(
`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`
)
throw new D3ROError(
ErrorCode.ContextCaptureFailed,
'nut-js 로드 실패. 스크린 컨텍스트를 사용할 수 없습니다.'
)
}
})()
}
await this._nutLoadPromise
if (!this._nutKeyboard) {
throw new D3ROError(ErrorCode.ContextCaptureFailed, 'nut-js not available')
}
return this._nutKeyboard
}
// ── 메인 API ──────────────────────────────────────────
/**
* + .
*
* @param captureSelectedText true이면 Ctrl+C로
*/
async captureContext(captureSelectedText = true): Promise<CaptureContextResult> {
const capturedAt = Date.now()
let appName: string | null = null
let windowTitle: string | null = null
let selectedText: string | null = null
let selectedTextAttempted = false
// 1. 활성 윈도우 정보 (PowerShell)
try {
const appInfo = await this._getActiveWindowInfo()
appName = appInfo.appName
windowTitle = appInfo.windowTitle
} catch (error) {
logger.warn(
`Failed to get active window info: ${error instanceof Error ? error.message : String(error)}`
)
// 활성 윈도우 감지 실패는 치명적이지 않으므로 계속 진행
}
// 2. 선택된 텍스트 캡처 (클립보드 방식)
if (captureSelectedText) {
selectedTextAttempted = true
try {
selectedText = await this._captureSelectedText()
} catch (error) {
logger.warn(
`Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}`
)
// 선택 텍스트 캡처 실패도 치명적이지 않음
}
}
const context: ScreenContext = {
appName,
windowTitle,
selectedText,
capturedAt,
}
logger.info(
`Context captured: app=${appName ?? 'unknown'}, title=${windowTitle ? windowTitle.substring(0, 40) : 'unknown'}, selectedText=${selectedText ? `${selectedText.length} chars` : 'none'}`
)
return { context, selectedTextAttempted }
}
/**
* ScreenContext를 LLM .
* .
*/
buildContextPrompt(ctx: ScreenContext): string {
const parts: string[] = []
if (ctx.appName || ctx.windowTitle || ctx.selectedText) {
parts.push('[컨텍스트]')
if (ctx.appName) {
parts.push(`활성 앱: ${ctx.appName}`)
}
if (ctx.windowTitle) {
parts.push(`윈도우: ${ctx.windowTitle}`)
}
if (ctx.selectedText) {
parts.push(`선택된 텍스트:`)
parts.push(ctx.selectedText)
}
parts.push('') // 빈 줄로 구분
}
return parts.join('\n')
}
/**
* .
*/
isEnabled(): boolean {
return configGet('screenContextEnabled')
}
/**
* /.
*/
setEnabled(enabled: boolean): void {
configSet('screenContextEnabled', enabled)
logger.info(`Screen context ${enabled ? 'enabled' : 'disabled'}`)
}
dispose(): void {
this._nutKeyboard = null
this._nutLoadPromise = null
logger.info('ScreenContextService disposed')
}
// ── 내부 구현 ─────────────────────────────────────────
/**
* Windows에서 .
* PowerShell을 GetForegroundWindow .
*/
private async _getActiveWindowInfo(): Promise<{
appName: string | null
windowTitle: string | null
}> {
if (process.platform !== 'win32') {
return { appName: null, windowTitle: null }
}
const { execFile } = await import('child_process')
const { promisify } = await import('util')
const execFileAsync = promisify(execFile)
// PowerShell 스크립트: GetForegroundWindow의 프로세스명과 윈도우 타이틀
const psScript = `
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class Win32 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
}
"@
$hwnd = [Win32]::GetForegroundWindow()
$pid = 0
[void][Win32]::GetWindowThreadProcessId($hwnd, [ref]$pid)
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
$sb = New-Object System.Text.StringBuilder 512
[void][Win32]::GetWindowText($hwnd, $sb, 512)
$procName = if ($proc) { $proc.ProcessName } else { '' }
$title = $sb.ToString()
"$procName$([char]10)$title"
`.trim()
try {
const { stdout } = await execFileAsync('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy', 'Bypass',
'-Command', psScript,
], { timeout: 3000 })
const lines = stdout.trim().split('\n')
const appName = lines[0]?.trim() || null
const windowTitle = lines[1]?.trim() || null
return { appName, windowTitle }
} catch (error) {
logger.warn(
`PowerShell active window query failed: ${error instanceof Error ? error.message : String(error)}`
)
return { appName: null, windowTitle: null }
}
}
/**
* .
* TextInsertService의 역방향: clipboard save Ctrl+C simulate clipboard read clipboard restore
*/
private async _captureSelectedText(): Promise<string | null> {
const nut = await this._ensureNut()
// 1. 기존 클립보드 저장
const snapshot = this._saveClipboard()
try {
// 2. 클립보드 비우기 (이전 내용이 남아있으면 "선택 없음"을 감지할 수 없으므로)
clipboard.clear()
// 3. Ctrl+C 시뮬레이션
await nut.pressKey(nut.Key.LeftControl, nut.Key.C)
await nut.releaseKey(nut.Key.LeftControl, nut.Key.C)
// 4. 클립보드에 텍스트가 복사될 때까지 대기
await this._sleep(150)
// 5. 클립보드에서 텍스트 읽기
const text = clipboard.readText()
// 6. 클립보드 복원
this._restoreClipboard(snapshot)
// 빈 문자열이면 선택된 텍스트 없음
if (!text || text.trim().length === 0) {
return null
}
return text
} catch (error) {
// 실패 시에도 클립보드 복원
try {
this._restoreClipboard(snapshot)
} catch {
logger.warn('Failed to restore clipboard after selected text capture error')
}
throw new D3ROError(
ErrorCode.ContextSelectedTextFailed,
`선택 텍스트 캡처 실패: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* (TextInsertService와 ).
*/
private _saveClipboard(): ClipboardSnapshot {
const text = clipboard.readText() || null
const html = clipboard.readHTML() || null
const rtf = clipboard.readRTF() || null
const image = clipboard.readImage()
const hasImage = image && !image.isEmpty()
return {
text,
html,
image: hasImage ? image : null,
rtf,
hasContent: !!(text || html || rtf || hasImage),
}
}
/**
* (TextInsertService와 ).
*/
private _restoreClipboard(snapshot: ClipboardSnapshot): void {
if (!snapshot.hasContent) {
clipboard.clear()
return
}
if (snapshot.text) {
clipboard.writeText(snapshot.text)
} else if (snapshot.html) {
clipboard.writeHTML(snapshot.html)
} else if (snapshot.rtf) {
clipboard.writeRTF(snapshot.rtf)
} else if (snapshot.image) {
clipboard.writeImage(snapshot.image)
}
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
// ── 싱글톤 ────────────────────────────────────────────
let instance: ScreenContextService | null = null
export function getScreenContextService(): ScreenContextService {
if (!instance) {
instance = new ScreenContextService()
}
return instance
}