feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
|
|
@ -1,342 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue