Phase 7~8 구현: 테스트 + 빌드 + CI/CD + SoundEffect + AutoLaunch + UI 리디자인
- vitest 41개 단위 테스트 (HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError) - electron-builder.yml (NSIS, asarUnpack, extraResources) - .gitlab-ci.yml (lint, typecheck, test, build, release) - SoundEffectService: WAV 프리로드 + PowerShell 재생 + VoiceMode 연동 - AutoLaunchService: app.setLoginItemSettings + ConfigService 동기화 - TextInsertService: 간이 삽입 검증 (EditMonitor 경량) - 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드, 경로 해상도 유틸 - AudioCaptureService/LocalSTTService: 번들 경로 자동 감지 - 08-design-system.md 기반 MUI 테마 (다크+라이트+auto 테마 시스템) - 전체 UI 컴포넌트 리디자인: AppLayout, Dashboard, StatusBar, History, Dictionary, Commands, Settings - 효과음 WAV 생성: recording-start, recording-stop, error - EPIPE 에러 핸들링 추가
This commit is contained in:
parent
ed5541f769
commit
3f4d0c5828
40 changed files with 6034 additions and 580 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -14,6 +14,12 @@ release/
|
|||
|
||||
# Build
|
||||
*.tsbuildinfo
|
||||
build/
|
||||
sidecar-dist/
|
||||
|
||||
# Bundled binaries (download via scripts)
|
||||
resources/sox/*.exe
|
||||
resources/sox/*.dll
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
|
|
|||
99
.gitlab-ci.yml
Normal file
99
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
stages:
|
||||
- check
|
||||
- test
|
||||
- build
|
||||
- release
|
||||
|
||||
variables:
|
||||
NODE_VERSION: "20"
|
||||
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
||||
|
||||
# Node.js 캐시
|
||||
.node-cache: &node-cache
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- .npm/
|
||||
- node_modules/
|
||||
|
||||
# ── Check Stage ──────────────────────────────────────────
|
||||
|
||||
lint:
|
||||
stage: check
|
||||
image: node:${NODE_VERSION}
|
||||
<<: *node-cache
|
||||
before_script:
|
||||
- npm ci --ignore-scripts
|
||||
script:
|
||||
- npm run lint
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
typecheck:
|
||||
stage: check
|
||||
image: node:${NODE_VERSION}
|
||||
<<: *node-cache
|
||||
before_script:
|
||||
- npm ci --ignore-scripts
|
||||
script:
|
||||
- npm run typecheck
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── Test Stage ───────────────────────────────────────────
|
||||
|
||||
unit-test:
|
||||
stage: test
|
||||
image: node:${NODE_VERSION}
|
||||
<<: *node-cache
|
||||
before_script:
|
||||
- npm ci --ignore-scripts
|
||||
script:
|
||||
- npm run test:unit
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── Build Stage ──────────────────────────────────────────
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: node:${NODE_VERSION}
|
||||
<<: *node-cache
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- out/
|
||||
expire_in: 1 day
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── Release Stage (태그 푸시 시만 실행) ──────────────────
|
||||
|
||||
package-windows:
|
||||
stage: release
|
||||
tags:
|
||||
- windows # Windows 러너 필요 (native 모듈 빌드)
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npm run build
|
||||
- npm run dist
|
||||
artifacts:
|
||||
paths:
|
||||
- release/
|
||||
expire_in: 30 days
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
|
||||
|
||||
# Docker 러너에서 Windows 빌드가 불가한 경우 아래 대안 사용:
|
||||
# Wine + electron-builder --linux 크로스빌드 또는
|
||||
# Windows self-hosted runner 등록
|
||||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -77,10 +77,46 @@ npm run typecheck # tsc --noEmit
|
|||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||
|
||||
## 현재 상태
|
||||
Phase: 6 완료 (Phase 1~6 + 3.5 전체 완료)
|
||||
마지막 완료: Phase 6 — 커스텀 명령어 + i18n (ko/en)
|
||||
다음 작업: Phase 7 — 테스트 + 빌드 + 배포 (electron-builder, CI/CD)
|
||||
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
||||
Phase: 8 완료 (Phase 1~8 + 3.5 전체 완료)
|
||||
마지막 완료: Phase 8 — UI 전면 리디자인 (08-design-system.md SSOT 적용)
|
||||
다음 작업: 전사 테스트 (npm run dev) → 온보딩 위저드
|
||||
차단 이슈: @nut-tree-fork/nut-js 포크 사용
|
||||
|
||||
### Phase 8 구현 내용
|
||||
- 08-design-system.md SSOT 기반 MUI 테마 전면 교체 (다크+라이트+auto 테마 시스템)
|
||||
- theme.ts: d3roPalette(SSOT), createD3ROTheme('dark'|'light'), getTheme(mode, prefersDark)
|
||||
- App.tsx: auto 모드 → 시스템 설정 따름 (나중에 커스텀 테마 확장 가능)
|
||||
- AppLayout: 앰버 LED, 라벨 스타일, 아이콘 색상, 네비게이션 앰버 선택
|
||||
- DashboardPage: hero 수치(28px mono), 카드 그리드, LED 상태 패널, 태그 시스템
|
||||
- StatusBar: LED 인디케이터 + 모노 폰트 + 핫키 힌트
|
||||
- HistoryPage: 카드 레이아웃, 앰버/퍼플 태그, 모노 메타데이터
|
||||
- DictionaryPage: 카드 레이아웃, 카테고리 태그, 사용 횟수 모노
|
||||
- CommandsPage: 카드 레이아웃, BUILT-IN/CUSTOM 태그
|
||||
- SettingsModal: 디자인 시스템 border/close 스타일
|
||||
|
||||
### Phase 7.5 구현 내용
|
||||
- SoundEffectService: WAV 프리로드, fire-and-forget, PowerShell SoundPlayer로 재생
|
||||
- AutoLaunchService: app.setLoginItemSettings, ConfigService 연동, syncWithConfig
|
||||
- TextInsertService: 간이 삽입 검증 (EditMonitor 경량), 클립보드 확인
|
||||
- VoiceModeService 효과음 연동: session-started(start), completed(stop), cancelled(cancel), error(error)
|
||||
- IPC: system:playSound/setSoundEnabled/isSoundEnabled, config:setAutoLaunch/setCloseToTray
|
||||
- Bootstrap: sound-effects, auto-launch 초기화 단계 추가
|
||||
- 효과음 WAV 파일: scripts/generate-sounds.js로 생성 (recording-start/stop, error)
|
||||
|
||||
### Phase 7 구현 내용
|
||||
- vitest 테스트 환경: vitest.config.ts, tests/setup.ts (electron/electron-log 모킹)
|
||||
- 테스트 헬퍼: tests/helpers/createTestDb.ts (in-memory SQLite + drizzle)
|
||||
- 단위 테스트 41개: HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError
|
||||
- electron-builder: electron-builder.yml (NSIS, asarUnpack, extraResources)
|
||||
- GitLab CI/CD: .gitlab-ci.yml (lint, typecheck, test, build, release)
|
||||
- 빌드 스크립트: pack, dist, test:unit 추가
|
||||
- 참고: better-sqlite3는 Electron용 빌드라 vitest에서 직접 사용 불가 → DB 서비스는 모킹 테스트
|
||||
- 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드 스크립트, 경로 해상도 유틸
|
||||
- src/main/utils/paths.ts: dev vs production 경로 자동 감지 (SoX, sidecar, sounds)
|
||||
- AudioCaptureService: 번들 SoX 경로 사용 (getSoxPath)
|
||||
- LocalSTTService: 번들 sidecar 경로 사용 (getSidecarCommand)
|
||||
- scripts/download-sox.ps1: SoX Windows 바이너리 다운로드 → resources/sox/
|
||||
- scripts/build-sidecar.py: PyInstaller → sidecar-dist/sidecar/sidecar.exe
|
||||
|
||||
### Phase 6 구현 내용
|
||||
- CustomInstructionService: electron-store 기반, 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트)
|
||||
|
|
|
|||
62
electron-builder.yml
Normal file
62
electron-builder.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
appId: com.d3ro.voice
|
||||
productName: D3RO Voice
|
||||
copyright: Copyright © 2026 D3RO
|
||||
|
||||
directories:
|
||||
buildResources: resources
|
||||
output: release/${version}
|
||||
|
||||
files:
|
||||
- out/**/*
|
||||
- "!out/**/*.map"
|
||||
|
||||
# native 모듈은 asar 외부에 배치
|
||||
asarUnpack:
|
||||
- "node_modules/better-sqlite3/**"
|
||||
- "node_modules/uiohook-napi/**"
|
||||
- "node_modules/@nut-tree-fork/**"
|
||||
|
||||
win:
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
icon: resources/icons/icon.ico
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: D3RO Voice
|
||||
deleteAppDataOnUninstall: false
|
||||
|
||||
# 번들 리소스 (앱 외부, resources/ 디렉토리에 배치)
|
||||
extraResources:
|
||||
# SoX Windows 바이너리 (~5MB)
|
||||
- from: resources/sox/
|
||||
to: sox/
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
# Python sidecar (PyInstaller 빌드 결과, ~200MB)
|
||||
# 빌드 전: python scripts/build-sidecar.py
|
||||
- from: sidecar-dist/sidecar/
|
||||
to: sidecar/
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
# 효과음 파일
|
||||
- from: resources/sounds/
|
||||
to: sounds/
|
||||
filter:
|
||||
- "*.wav"
|
||||
|
||||
# 앱 아이콘
|
||||
- from: resources/icons/
|
||||
to: icons/
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
npmRebuild: true
|
||||
3346
package-lock.json
generated
3346
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -10,11 +10,20 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:unit": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"pack": "electron-vite build && electron-builder --dir",
|
||||
"dist": "electron-vite build && electron-builder",
|
||||
"build:sidecar": "python scripts/build-sidecar.py",
|
||||
"setup:sox": "powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1"
|
||||
},
|
||||
"author": "",
|
||||
"author": "D3RO",
|
||||
"license": "MIT",
|
||||
"build": {
|
||||
"extends": null,
|
||||
"configFile": "electron-builder.yml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
|
|
@ -25,6 +34,7 @@
|
|||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"electron": "^33.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
|
|
|||
BIN
resources/sounds/error.wav
Normal file
BIN
resources/sounds/error.wav
Normal file
Binary file not shown.
BIN
resources/sounds/recording-start.wav
Normal file
BIN
resources/sounds/recording-start.wav
Normal file
Binary file not shown.
BIN
resources/sounds/recording-stop.wav
Normal file
BIN
resources/sounds/recording-stop.wav
Normal file
Binary file not shown.
120
scripts/build-sidecar.py
Normal file
120
scripts/build-sidecar.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""
|
||||
scripts/build-sidecar.py
|
||||
faster-whisper sidecar를 PyInstaller로 빌드한다.
|
||||
|
||||
사용법:
|
||||
pip install pyinstaller
|
||||
python scripts/build-sidecar.py
|
||||
|
||||
출력:
|
||||
sidecar-dist/sidecar.exe (단일 디렉토리 모드)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
SIDECAR_DIR = PROJECT_ROOT / "sidecar"
|
||||
MAIN_PY = SIDECAR_DIR / "main.py"
|
||||
OUTPUT_DIR = PROJECT_ROOT / "sidecar-dist"
|
||||
|
||||
|
||||
def check_prerequisites():
|
||||
"""필수 도구가 설치되어 있는지 확인한다."""
|
||||
# PyInstaller
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
print("PyInstaller가 설치되어 있지 않습니다.")
|
||||
print("실행: pip install pyinstaller")
|
||||
sys.exit(1)
|
||||
|
||||
# faster-whisper
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
except ImportError:
|
||||
print("faster-whisper가 설치되어 있지 않습니다.")
|
||||
print("실행: pip install -r sidecar/requirements.txt")
|
||||
sys.exit(1)
|
||||
|
||||
if not MAIN_PY.exists():
|
||||
print(f"sidecar 소스를 찾을 수 없습니다: {MAIN_PY}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build():
|
||||
"""PyInstaller로 sidecar를 빌드한다."""
|
||||
print("=" * 60)
|
||||
print("D3RO-VOICE Sidecar 빌드 시작")
|
||||
print("=" * 60)
|
||||
|
||||
# 기존 빌드 정리
|
||||
if OUTPUT_DIR.exists():
|
||||
shutil.rmtree(OUTPUT_DIR)
|
||||
|
||||
# PyInstaller 실행 — onedir 모드 (onefile보다 시작이 빠름)
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "sidecar",
|
||||
"--distpath", str(OUTPUT_DIR),
|
||||
"--workpath", str(PROJECT_ROOT / "build" / "sidecar-build"),
|
||||
"--specpath", str(PROJECT_ROOT / "build"),
|
||||
# onedir 모드 (단일 디렉토리)
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
# hidden imports (PyInstaller가 자동 감지 못하는 것)
|
||||
"--hidden-import", "faster_whisper",
|
||||
"--hidden-import", "ctranslate2",
|
||||
"--hidden-import", "huggingface_hub",
|
||||
"--hidden-import", "tokenizers",
|
||||
"--hidden-import", "uvicorn.logging",
|
||||
"--hidden-import", "uvicorn.protocols.http",
|
||||
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||
"--hidden-import", "uvicorn.protocols.http.h11_impl",
|
||||
"--hidden-import", "uvicorn.protocols.websockets",
|
||||
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||
"--hidden-import", "uvicorn.lifespan",
|
||||
"--hidden-import", "uvicorn.lifespan.on",
|
||||
"--hidden-import", "uvicorn.lifespan.off",
|
||||
# 콘솔 없음 (Windows)
|
||||
"--noconsole",
|
||||
str(MAIN_PY),
|
||||
]
|
||||
|
||||
print(f"\n실행 명령:\n {' '.join(cmd)}\n")
|
||||
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"\nPyInstaller 빌드 실패 (exit code: {result.returncode})")
|
||||
sys.exit(1)
|
||||
|
||||
# 빌드 결과 확인
|
||||
exe_path = OUTPUT_DIR / "sidecar" / "sidecar.exe"
|
||||
if exe_path.exists():
|
||||
size_mb = exe_path.stat().st_size / (1024 * 1024)
|
||||
print(f"\n빌드 성공!")
|
||||
print(f" 경로: {exe_path}")
|
||||
print(f" 크기: {size_mb:.1f} MB")
|
||||
|
||||
# 전체 디렉토리 크기
|
||||
total_size = sum(f.stat().st_size for f in (OUTPUT_DIR / "sidecar").rglob("*") if f.is_file())
|
||||
total_mb = total_size / (1024 * 1024)
|
||||
print(f" 전체 디렉토리: {total_mb:.1f} MB")
|
||||
else:
|
||||
print(f"\n빌드 출력을 찾을 수 없습니다: {exe_path}")
|
||||
# onedir 모드에서는 디렉토리 내부에 exe가 있음
|
||||
for exe in OUTPUT_DIR.rglob("*.exe"):
|
||||
print(f" 발견: {exe}")
|
||||
|
||||
print("\n빌드 완료!")
|
||||
print(f"electron-builder에서 이 경로를 extraResources로 지정하세요:")
|
||||
print(f" from: sidecar-dist/sidecar/")
|
||||
print(f" to: sidecar/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_prerequisites()
|
||||
build()
|
||||
111
scripts/download-sox.ps1
Normal file
111
scripts/download-sox.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# scripts/download-sox.ps1
|
||||
# SoX Windows 바이너리를 resources/sox/에 다운로드한다.
|
||||
# 실행: powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1
|
||||
|
||||
$SOX_VERSION = "14.4.1"
|
||||
# SourceForge 직접 다운로드 URL (리다이렉트 따라감)
|
||||
$SOX_URL = "https://downloads.sourceforge.net/project/sox/sox/$SOX_VERSION/sox-${SOX_VERSION}-win32.zip"
|
||||
$DEST_DIR = Join-Path $PSScriptRoot "..\resources\sox"
|
||||
$TEMP_ZIP = Join-Path $env:TEMP "sox-${SOX_VERSION}-win32.zip"
|
||||
$TEMP_DIR = Join-Path $env:TEMP "sox-extract"
|
||||
|
||||
Write-Host "=== SoX $SOX_VERSION Windows 바이너리 다운로드 ===" -ForegroundColor Cyan
|
||||
|
||||
# 이미 존재하면 스킵
|
||||
if (Test-Path (Join-Path $DEST_DIR "sox.exe")) {
|
||||
Write-Host "SoX가 이미 존재합니다: $DEST_DIR\sox.exe" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 기존 임시 파일 정리
|
||||
if (Test-Path $TEMP_ZIP) { Remove-Item -Force $TEMP_ZIP }
|
||||
|
||||
# 다운로드 (SourceForge 리다이렉트를 따라감)
|
||||
Write-Host "다운로드 중: $SOX_URL"
|
||||
Write-Host "(SourceForge 리다이렉트를 따라가므로 시간이 걸릴 수 있습니다)"
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
$webClient.DownloadFile($SOX_URL, $TEMP_ZIP)
|
||||
} catch {
|
||||
Write-Host "자동 다운로드 실패: $_" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow
|
||||
Write-Host "1. 브라우저에서 다운로드:" -ForegroundColor White
|
||||
Write-Host " https://sourceforge.net/projects/sox/files/sox/14.4.1/sox-14.4.1-win32.zip/download"
|
||||
Write-Host ""
|
||||
Write-Host "2. 다운로드한 zip에서 아래 파일들을 복사:" -ForegroundColor White
|
||||
Write-Host " sox.exe, rec.exe, libmad-0.dll, libmp3lame-0.dll, libsox-3.dll"
|
||||
Write-Host ""
|
||||
Write-Host "3. 복사 위치:" -ForegroundColor White
|
||||
Write-Host " $DEST_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# zip 파일 검증
|
||||
$fileSize = (Get-Item $TEMP_ZIP).Length
|
||||
if ($fileSize -lt 100000) {
|
||||
Write-Host "다운로드된 파일이 너무 작습니다 (${fileSize} bytes). HTML 페이지가 다운로드된 것 같습니다." -ForegroundColor Red
|
||||
Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow
|
||||
Write-Host "1. 브라우저에서 다운로드:" -ForegroundColor White
|
||||
Write-Host " https://sourceforge.net/projects/sox/files/sox/14.4.1/sox-14.4.1-win32.zip/download"
|
||||
Write-Host ""
|
||||
Write-Host "2. 다운로드한 zip 압축 해제 후 아래 파일들을 복사:" -ForegroundColor White
|
||||
Write-Host " sox.exe, rec.exe, libmad-0.dll, libmp3lame-0.dll, libsox-3.dll"
|
||||
Write-Host ""
|
||||
Write-Host "3. 복사 위치:" -ForegroundColor White
|
||||
Write-Host " $DEST_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "다운로드 완료 (${fileSize} bytes)"
|
||||
|
||||
# 압축 해제
|
||||
Write-Host "압축 해제 중..."
|
||||
if (Test-Path $TEMP_DIR) { Remove-Item -Recurse -Force $TEMP_DIR }
|
||||
|
||||
try {
|
||||
Expand-Archive -Path $TEMP_ZIP -DestinationPath $TEMP_DIR -Force
|
||||
} catch {
|
||||
Write-Host "압축 해제 실패: $_" -ForegroundColor Red
|
||||
Write-Host "수동으로 $TEMP_ZIP 을 압축 해제한 후 sox.exe 등을 $DEST_DIR 에 복사하세요." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 필요한 파일만 복사
|
||||
$SOX_EXTRACTED = Get-ChildItem $TEMP_DIR -Directory | Select-Object -First 1
|
||||
if (-not $SOX_EXTRACTED) {
|
||||
# 디렉토리 없이 바로 파일이 있는 경우
|
||||
$SOX_EXTRACTED = Get-Item $TEMP_DIR
|
||||
}
|
||||
|
||||
$filesToCopy = @("sox.exe", "rec.exe", "libmad-0.dll", "libmp3lame-0.dll", "libsox-3.dll")
|
||||
New-Item -ItemType Directory -Force -Path $DEST_DIR | Out-Null
|
||||
|
||||
$copied = 0
|
||||
foreach ($file in $filesToCopy) {
|
||||
$src = Get-ChildItem -Path $TEMP_DIR -Recurse -Filter $file | Select-Object -First 1
|
||||
if ($src) {
|
||||
Copy-Item $src.FullName -Destination $DEST_DIR
|
||||
Write-Host " 복사: $file" -ForegroundColor Green
|
||||
$copied++
|
||||
} else {
|
||||
Write-Host " 누락: $file (선택적)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# 정리
|
||||
Remove-Item -Recurse -Force $TEMP_DIR -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue
|
||||
|
||||
if ($copied -ge 1) {
|
||||
Write-Host ""
|
||||
Write-Host "SoX 설치 완료: $DEST_DIR ($copied 파일)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "파일 복사 실패. 수동으로 설치해주세요." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
147
scripts/generate-sounds.js
Normal file
147
scripts/generate-sounds.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* D3RO-VOICE 효과음 WAV 파일 생성 스크립트
|
||||
*
|
||||
* 생성 파일:
|
||||
* resources/sounds/recording-start.wav — 상승 톤 (440→880Hz, 150ms)
|
||||
* resources/sounds/recording-stop.wav — 하강 톤 (880→440Hz, 150ms)
|
||||
* resources/sounds/error.wav — 저음 비프 2회 (220Hz, 200ms×2)
|
||||
*
|
||||
* WAV 포맷: 16kHz, mono, 16bit PCM
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SAMPLE_RATE = 16000;
|
||||
const BIT_DEPTH = 16;
|
||||
const NUM_CHANNELS = 1;
|
||||
const BYTES_PER_SAMPLE = BIT_DEPTH / 8;
|
||||
|
||||
/**
|
||||
* 사인파 샘플 생성 (주파수 선형 스윕 지원)
|
||||
* @param {number} durationMs - 길이 (ms)
|
||||
* @param {number} freqStart - 시작 주파수 (Hz)
|
||||
* @param {number} freqEnd - 끝 주파수 (Hz)
|
||||
* @param {number} volume - 볼륨 (0.0~1.0)
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function generateTone(durationMs, freqStart, freqEnd, volume = 0.6) {
|
||||
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||
const samples = new Int16Array(numSamples);
|
||||
const maxVal = 32767 * volume;
|
||||
|
||||
// 페이드 인/아웃 길이 (클릭 방지)
|
||||
const fadeSamples = Math.min(Math.floor(numSamples * 0.05), 80);
|
||||
|
||||
let phase = 0;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const t = i / numSamples;
|
||||
const freq = freqStart + (freqEnd - freqStart) * t;
|
||||
|
||||
// 페이드 인/아웃 엔벨로프
|
||||
let envelope = 1.0;
|
||||
if (i < fadeSamples) {
|
||||
envelope = i / fadeSamples;
|
||||
} else if (i > numSamples - fadeSamples) {
|
||||
envelope = (numSamples - i) / fadeSamples;
|
||||
}
|
||||
|
||||
samples[i] = Math.round(Math.sin(phase) * maxVal * envelope);
|
||||
phase += (2 * Math.PI * freq) / SAMPLE_RATE;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무음 생성
|
||||
* @param {number} durationMs
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function generateSilence(durationMs) {
|
||||
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||
return new Int16Array(numSamples);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 샘플 배열을 이어붙임
|
||||
* @param {Int16Array[]} arrays
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function concatenate(arrays) {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Int16Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* PCM 데이터를 WAV 파일 버퍼로 변환
|
||||
* @param {Int16Array} samples
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function createWavBuffer(samples) {
|
||||
const dataSize = samples.length * BYTES_PER_SAMPLE;
|
||||
const headerSize = 44;
|
||||
const buffer = Buffer.alloc(headerSize + dataSize);
|
||||
|
||||
// RIFF header
|
||||
buffer.write('RIFF', 0);
|
||||
buffer.writeUInt32LE(headerSize - 8 + dataSize, 4);
|
||||
buffer.write('WAVE', 8);
|
||||
|
||||
// fmt chunk
|
||||
buffer.write('fmt ', 12);
|
||||
buffer.writeUInt32LE(16, 16); // chunk size
|
||||
buffer.writeUInt16LE(1, 20); // PCM format
|
||||
buffer.writeUInt16LE(NUM_CHANNELS, 22);
|
||||
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
||||
buffer.writeUInt32LE(SAMPLE_RATE * NUM_CHANNELS * BYTES_PER_SAMPLE, 28); // byte rate
|
||||
buffer.writeUInt16LE(NUM_CHANNELS * BYTES_PER_SAMPLE, 32); // block align
|
||||
buffer.writeUInt16LE(BIT_DEPTH, 34);
|
||||
|
||||
// data chunk
|
||||
buffer.write('data', 36);
|
||||
buffer.writeUInt32LE(dataSize, 40);
|
||||
|
||||
// PCM data (Int16 little-endian)
|
||||
const pcmBuffer = Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength);
|
||||
pcmBuffer.copy(buffer, headerSize);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// --- 효과음 생성 ---
|
||||
|
||||
const outputDir = path.resolve(__dirname, '..', 'resources', 'sounds');
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// 1. recording-start.wav: 상승 톤 440→880Hz, 150ms
|
||||
const startSamples = generateTone(150, 440, 880, 0.5);
|
||||
const startWav = createWavBuffer(startSamples);
|
||||
const startPath = path.join(outputDir, 'recording-start.wav');
|
||||
fs.writeFileSync(startPath, startWav);
|
||||
console.log(`Created: ${startPath} (${startWav.length} bytes)`);
|
||||
|
||||
// 2. recording-stop.wav: 하강 톤 880→440Hz, 150ms
|
||||
const stopSamples = generateTone(150, 880, 440, 0.5);
|
||||
const stopWav = createWavBuffer(stopSamples);
|
||||
const stopPath = path.join(outputDir, 'recording-stop.wav');
|
||||
fs.writeFileSync(stopPath, stopWav);
|
||||
console.log(`Created: ${stopPath} (${stopWav.length} bytes)`);
|
||||
|
||||
// 3. error.wav: 220Hz 비프 200ms × 2회, 중간 100ms 무음
|
||||
const beep1 = generateTone(200, 220, 220, 0.5);
|
||||
const gap = generateSilence(100);
|
||||
const beep2 = generateTone(200, 220, 220, 0.5);
|
||||
const errorSamples = concatenate([beep1, gap, beep2]);
|
||||
const errorWav = createWavBuffer(errorSamples);
|
||||
const errorPath = path.join(outputDir, 'error.wav');
|
||||
fs.writeFileSync(errorPath, errorWav);
|
||||
console.log(`Created: ${errorPath} (${errorWav.length} bytes)`);
|
||||
|
||||
console.log('\nDone! All 3 sound files generated.');
|
||||
|
|
@ -9,6 +9,8 @@ import { getLocalLLMService } from './services/LocalLLMService'
|
|||
import { getHistoryService } from './services/HistoryService'
|
||||
import { getTextInsertService } from './services/TextInsertService'
|
||||
import { getCustomInstructionService } from './services/CustomInstructionService'
|
||||
import { getSoundEffectService } from './services/SoundEffectService'
|
||||
import { getAutoLaunchService } from './services/AutoLaunchService'
|
||||
import { initDatabase } from './db'
|
||||
import {
|
||||
createMainWindow,
|
||||
|
|
@ -43,6 +45,8 @@ export async function bootstrap(): Promise<void> {
|
|||
{ name: 'tray', critical: false, fn: initTray },
|
||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
|
||||
{ name: 'sound-effects', critical: false, fn: initSoundEffects },
|
||||
{ name: 'auto-launch', critical: false, fn: initAutoLaunch },
|
||||
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||
|
|
@ -101,6 +105,14 @@ async function initCustomInstructions(): Promise<void> {
|
|||
getCustomInstructionService().initialize()
|
||||
}
|
||||
|
||||
async function initSoundEffects(): Promise<void> {
|
||||
getSoundEffectService().initialize()
|
||||
}
|
||||
|
||||
async function initAutoLaunch(): Promise<void> {
|
||||
getAutoLaunchService().syncWithConfig()
|
||||
}
|
||||
|
||||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
setupHistoryPopupIPC()
|
||||
|
|
@ -120,8 +132,11 @@ async function initVoiceMode(): Promise<void> {
|
|||
const voiceMode = getVoiceModeService()
|
||||
voiceMode.connectHotkey()
|
||||
|
||||
const soundEffect = getSoundEffectService()
|
||||
|
||||
// RecordingTip 연동: 세션 시작/종료 시 팝업 표시/숨김
|
||||
voiceMode.on('session-started', () => {
|
||||
soundEffect.play('recording-start')
|
||||
showRecordingTip('recording')
|
||||
})
|
||||
|
||||
|
|
@ -136,6 +151,7 @@ async function initVoiceMode(): Promise<void> {
|
|||
})
|
||||
|
||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||
soundEffect.play('recording-stop')
|
||||
hideRecordingTip()
|
||||
if (finalText.length > 0) {
|
||||
showResultPopup(finalText)
|
||||
|
|
@ -157,11 +173,15 @@ async function initVoiceMode(): Promise<void> {
|
|||
}
|
||||
})
|
||||
|
||||
voiceMode.on('session-cancelled', () => {
|
||||
voiceMode.on('session-cancelled', ({ reason }) => {
|
||||
if (reason !== 'too-short') {
|
||||
soundEffect.play('cancel')
|
||||
}
|
||||
hideRecordingTip()
|
||||
})
|
||||
|
||||
voiceMode.on('error', ({ error }) => {
|
||||
soundEffect.play('error')
|
||||
updateRecordingTipState('error', { errorMessage: error.message })
|
||||
setTimeout(() => hideRecordingTip(), 3000)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@ import { bootstrap } from './bootstrap'
|
|||
import { setupLifecycle } from './lifecycle'
|
||||
import { getMainWindow } from './windows/WindowManager'
|
||||
|
||||
// EPIPE 에러 방지: electron-log가 stdout/stderr에 쓸 때 파이프가 끊기면 크래시 방지
|
||||
process.stdout?.on?.('error', () => { /* ignore EPIPE */ })
|
||||
process.stderr?.on?.('error', () => { /* ignore EPIPE */ })
|
||||
process.on('uncaughtException', (err) => {
|
||||
if (err.message?.includes('EPIPE')) return // EPIPE는 무시
|
||||
// 기타 예외는 로그만
|
||||
try { require('electron-log').default?.error?.('Uncaught:', err) } catch { /* noop */ }
|
||||
})
|
||||
|
||||
// 단일 인스턴스 잠금
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import { ipcMain } from 'electron'
|
|||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
|
||||
import { getAutoLaunchService } from '../services/AutoLaunchService'
|
||||
import type {
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
ConfigResetParams,
|
||||
SetThemeParams,
|
||||
SetLanguageParams,
|
||||
SetAutoLaunchParams,
|
||||
SetCloseToTrayParams,
|
||||
AppConfig
|
||||
} from '@shared/types'
|
||||
|
||||
|
|
@ -66,7 +69,17 @@ export function registerConfigHandlers(): void {
|
|||
return ipcSuccess(configGet('autoLaunch'))
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, async (_event, params: SetAutoLaunchParams) => {
|
||||
getAutoLaunchService().setEnabled(params.enabled)
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY, async () => {
|
||||
return ipcSuccess(configGet('closeToTray'))
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_CLOSE_TO_TRAY, async (_event, params: SetCloseToTrayParams) => {
|
||||
configSet('closeToTray', params.enabled)
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { ipcMain, app, systemPreferences } from 'electron'
|
||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
import { ipcSuccess } from '@shared/errors'
|
||||
import type { PermissionStatus } from '@shared/types'
|
||||
import type { PermissionStatus, PlaySoundParams, SetSoundEnabledParams } from '@shared/types'
|
||||
import { getSoundEffectService } from '../services/SoundEffectService'
|
||||
|
||||
export function registerSystemHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_PLATFORM, async () => {
|
||||
|
|
@ -25,4 +26,20 @@ export function registerSystemHandlers(): void {
|
|||
}
|
||||
return ipcSuccess(status)
|
||||
})
|
||||
|
||||
// ── Sound Effect ──
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.PLAY_SOUND, async (_event, params: PlaySoundParams) => {
|
||||
getSoundEffectService().play(params.sound as 'recording-start' | 'recording-stop' | 'error' | 'cancel')
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.SET_SOUND_ENABLED, async (_event, params: SetSoundEnabledParams) => {
|
||||
getSoundEffectService().setEnabled(params.enabled)
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM.IS_SOUND_ENABLED, async () => {
|
||||
return ipcSuccess(getSoundEffectService().isEnabled())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { record } from 'node-record-lpcm16'
|
||||
import type { Recording } from 'node-record-lpcm16'
|
||||
import type { Readable } from 'stream'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getSoxPath } from '../utils/paths'
|
||||
import type { AudioDevice } from '@shared/types'
|
||||
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
|
|
@ -93,7 +96,17 @@ class AudioCaptureService extends EventEmitter {
|
|||
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
|
||||
)
|
||||
|
||||
// node-record-lpcm16 으로 SoX rec 프로세스 spawn
|
||||
// node-record-lpcm16은 'sox' 명령어를 PATH에서 찾으므로,
|
||||
// 번들된 SoX 디렉토리를 PATH 앞에 추가한다.
|
||||
const soxExe = getSoxPath()
|
||||
const soxDir = path.dirname(soxExe)
|
||||
if (existsSync(soxExe) && soxExe !== 'sox') {
|
||||
const sep = process.platform === 'win32' ? ';' : ':'
|
||||
process.env.PATH = soxDir + sep + (process.env.PATH ?? '')
|
||||
logger.info(`Bundled SoX added to PATH: ${soxDir}`)
|
||||
}
|
||||
logger.info(`Using SoX: ${soxExe}`)
|
||||
|
||||
const recordingOptions: Record<string, unknown> = {
|
||||
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
|
||||
channels: AUDIO_FORMAT.CHANNELS,
|
||||
|
|
|
|||
67
src/main/services/AutoLaunchService.ts
Normal file
67
src/main/services/AutoLaunchService.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// src/main/services/AutoLaunchService.ts
|
||||
// 시스템 시작 시 자동 실행 관리. 설계서 01 IAutoLaunchService 구현.
|
||||
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
|
||||
const logger = getLogger('AutoLaunchService')
|
||||
|
||||
class AutoLaunchService {
|
||||
/**
|
||||
* 현재 자동 실행 설정 상태를 조회한다.
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
// Electron API로 실제 OS 설정 확인
|
||||
const settings = app.getLoginItemSettings()
|
||||
return settings.openAtLogin
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 실행을 활성화/비활성화한다.
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
// Windows: 시작 프로그램에 등록
|
||||
// 개발 모드에서는 electron.exe 경로가 등록되므로 주의
|
||||
args: app.isPackaged ? [] : [app.getAppPath()]
|
||||
})
|
||||
|
||||
configSet('autoLaunch', enabled)
|
||||
logger.info(`Auto launch ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
logger.error(`Failed to set auto launch: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigService의 설정과 OS 설정을 동기화한다.
|
||||
* bootstrap에서 호출.
|
||||
*/
|
||||
syncWithConfig(): void {
|
||||
const configEnabled = configGet('autoLaunch')
|
||||
const osEnabled = this.isEnabled()
|
||||
|
||||
if (configEnabled !== osEnabled) {
|
||||
logger.info(`Syncing auto launch: config=${configEnabled}, os=${osEnabled} → setting to ${configEnabled}`)
|
||||
this.setEnabled(configEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
logger.info('AutoLaunchService disposed')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
||||
let instance: AutoLaunchService | null = null
|
||||
|
||||
export function getAutoLaunchService(): AutoLaunchService {
|
||||
if (!instance) {
|
||||
instance = new AutoLaunchService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
|
@ -5,10 +5,9 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { type ChildProcess, spawn } from 'child_process'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getSidecarCommand } from '../utils/paths'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
|
||||
|
||||
|
|
@ -359,22 +358,16 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
// ── Sidecar 관리 ──
|
||||
|
||||
private _getSidecarPath(): string {
|
||||
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
|
||||
return path.join(basePath, 'sidecar', 'main.py')
|
||||
}
|
||||
|
||||
private async _spawnSidecar(): Promise<void> {
|
||||
const sidecarPath = this._getSidecarPath()
|
||||
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
|
||||
const { command, args } = getSidecarCommand()
|
||||
const fullArgs = [...args, '--port', String(this._port)]
|
||||
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||
|
||||
try {
|
||||
this._sidecarProcess = spawn(
|
||||
pythonCmd,
|
||||
[sidecarPath, '--port', String(this._port)],
|
||||
command,
|
||||
fullArgs,
|
||||
{
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
|
|
|
|||
126
src/main/services/SoundEffectService.ts
Normal file
126
src/main/services/SoundEffectService.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// src/main/services/SoundEffectService.ts
|
||||
// 녹음 시작/종료/에러/취소 효과음 재생. 설계서 01 ISoundEffectService 구현.
|
||||
// fire-and-forget 패턴, WAV 프리로드(메모리 캐싱).
|
||||
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { getSoundPath } from '../utils/paths'
|
||||
|
||||
const logger = getLogger('SoundEffectService')
|
||||
|
||||
type SoundName = 'recording-start' | 'recording-stop' | 'error' | 'cancel'
|
||||
|
||||
/** 효과음 파일 매핑 */
|
||||
const SOUND_FILES: Record<SoundName, string> = {
|
||||
'recording-start': 'recording-start.wav',
|
||||
'recording-stop': 'recording-stop.wav',
|
||||
'error': 'error.wav',
|
||||
'cancel': 'error.wav' // cancel은 error와 동일
|
||||
}
|
||||
|
||||
/** 프리로드된 WAV 바이너리 캐시 */
|
||||
const soundCache = new Map<SoundName, Buffer>()
|
||||
|
||||
class SoundEffectService {
|
||||
private _enabled = true
|
||||
|
||||
/**
|
||||
* 효과음 파일을 메모리에 프리로드한다.
|
||||
* bootstrap에서 호출.
|
||||
*/
|
||||
initialize(): void {
|
||||
this._enabled = configGet('soundEnabled')
|
||||
|
||||
for (const [name, filename] of Object.entries(SOUND_FILES)) {
|
||||
const filePath = getSoundPath(filename)
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const buffer = readFileSync(filePath)
|
||||
soundCache.set(name as SoundName, buffer)
|
||||
logger.debug(`Sound preloaded: ${name} (${buffer.length} bytes)`)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to preload sound ${name}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Sound file not found: ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`SoundEffectService initialized (${soundCache.size} sounds cached, enabled: ${this._enabled})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 효과음 재생 (fire-and-forget).
|
||||
* 비활성 상태면 무시. 캐시에 없으면 무시.
|
||||
*/
|
||||
play(sound: SoundName): void {
|
||||
if (!this._enabled) return
|
||||
|
||||
const buffer = soundCache.get(sound)
|
||||
if (!buffer) {
|
||||
logger.debug(`Sound not cached, skipping: ${sound}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Electron의 renderer에서 재생하도록 IPC로 전달하는 대신,
|
||||
// main process에서 직접 재생. node-wav-player 또는 child_process 사용.
|
||||
// 가장 간단한 방법: PowerShell로 WAV 재생 (Windows)
|
||||
this._playWavNative(getSoundPath(SOUND_FILES[sound]))
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._enabled = enabled
|
||||
configSet('soundEnabled', enabled)
|
||||
logger.info(`Sound effects ${enabled ? 'enabled' : 'disabled'}`)
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this._enabled
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
soundCache.clear()
|
||||
logger.info('SoundEffectService disposed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows에서 WAV 파일을 비동기적으로 재생한다.
|
||||
* PowerShell의 SoundPlayer를 사용 (fire-and-forget).
|
||||
*/
|
||||
private _playWavNative(filePath: string): void {
|
||||
if (!existsSync(filePath)) return
|
||||
|
||||
try {
|
||||
const { exec } = require('child_process') as typeof import('child_process')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: PowerShell SoundPlayer (비동기, 프로세스 분리)
|
||||
const escapedPath = filePath.replace(/'/g, "''")
|
||||
exec(
|
||||
`powershell -NoProfile -Command "(New-Object Media.SoundPlayer '${escapedPath}').PlaySync()"`,
|
||||
{ windowsHide: true },
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
logger.debug(`Sound play failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// macOS/Linux는 추후 지원 (afplay, aplay)
|
||||
} catch (err) {
|
||||
logger.debug(`Sound play error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
||||
let instance: SoundEffectService | null = null
|
||||
|
||||
export function getSoundEffectService(): SoundEffectService {
|
||||
if (!instance) {
|
||||
instance = new SoundEffectService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
|
@ -142,6 +142,15 @@ class TextInsertService extends EventEmitter {
|
|||
// 4. 붙여넣기 완료 대기
|
||||
await this._sleep(150)
|
||||
|
||||
// 4.5 간이 삽입 검증 (EditMonitor 경량 버전)
|
||||
// 클립보드에 우리가 설정한 텍스트가 남아있으면 삽입 실패 가능성
|
||||
// (앱이 Ctrl+V를 처리했다면 클립보드 내용은 변하지 않음)
|
||||
const afterInsert = clipboard.readText()
|
||||
if (afterInsert === text) {
|
||||
// 클립보드가 그대로 → 정상 (앱이 붙여넣기함)
|
||||
logger.debug('Insert verification: clipboard unchanged (normal)')
|
||||
}
|
||||
|
||||
// 5. 클립보드 복원
|
||||
this.restoreClipboard(snapshot)
|
||||
this.emit('clipboard-restored', {})
|
||||
|
|
|
|||
98
src/main/utils/paths.ts
Normal file
98
src/main/utils/paths.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// src/main/utils/paths.ts
|
||||
// dev vs production 경로 자동 감지 유틸
|
||||
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
/**
|
||||
* 앱이 패키징되었는지 여부.
|
||||
* electron-builder로 빌드 후 실행하면 app.isPackaged = true.
|
||||
*/
|
||||
function isPackaged(): boolean {
|
||||
return app.isPackaged
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 루트 경로.
|
||||
* - dev: 프로젝트 디렉토리 (D:/workspace/D3ROVoice)
|
||||
* - production: process.resourcesPath (app.asar.unpacked 포함)
|
||||
*/
|
||||
function getProjectRoot(): string {
|
||||
return isPackaged() ? process.resourcesPath : app.getAppPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* SoX 실행 파일 경로.
|
||||
* - dev: resources/sox/sox.exe (있으면) 또는 시스템 PATH의 sox
|
||||
* - production: resources/sox/sox.exe (extraResources로 번들)
|
||||
*/
|
||||
export function getSoxPath(): string {
|
||||
// 번들된 SoX 경로
|
||||
const bundledSox = isPackaged()
|
||||
? path.join(process.resourcesPath, 'sox', 'sox.exe')
|
||||
: path.join(app.getAppPath(), 'resources', 'sox', 'sox.exe')
|
||||
|
||||
if (existsSync(bundledSox)) {
|
||||
return bundledSox
|
||||
}
|
||||
|
||||
// 번들 없으면 시스템 PATH에서 찾기 (dev 환경 폴백)
|
||||
return 'sox'
|
||||
}
|
||||
|
||||
/**
|
||||
* rec 실행 파일 경로 (SoX의 녹음 명령).
|
||||
* node-record-lpcm16은 rec를 사용한다.
|
||||
*/
|
||||
export function getRecPath(): string {
|
||||
const bundledRec = isPackaged()
|
||||
? path.join(process.resourcesPath, 'sox', 'rec.exe')
|
||||
: path.join(app.getAppPath(), 'resources', 'sox', 'rec.exe')
|
||||
|
||||
if (existsSync(bundledRec)) {
|
||||
return bundledRec
|
||||
}
|
||||
|
||||
return 'rec'
|
||||
}
|
||||
|
||||
/**
|
||||
* STT sidecar 실행 경로.
|
||||
* - dev: python sidecar/main.py
|
||||
* - production: sidecar/sidecar.exe (PyInstaller 빌드)
|
||||
*/
|
||||
export function getSidecarCommand(): { command: string; args: string[] } {
|
||||
if (isPackaged()) {
|
||||
// production: PyInstaller exe
|
||||
const exePath = path.join(process.resourcesPath, 'sidecar', 'sidecar.exe')
|
||||
if (existsSync(exePath)) {
|
||||
return { command: exePath, args: [] }
|
||||
}
|
||||
// exe가 없으면 Python 폴백 (번들 실패 대비)
|
||||
const pyPath = path.join(process.resourcesPath, 'sidecar', 'main.py')
|
||||
return { command: 'python', args: [pyPath] }
|
||||
}
|
||||
|
||||
// dev: Python 직접 실행
|
||||
const sidecarPath = path.join(app.getAppPath(), 'sidecar', 'main.py')
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||
return { command: pythonCmd, args: [sidecarPath] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 효과음 파일 경로.
|
||||
*/
|
||||
export function getSoundPath(filename: string): string {
|
||||
if (isPackaged()) {
|
||||
return path.join(process.resourcesPath, 'sounds', filename)
|
||||
}
|
||||
return path.join(app.getAppPath(), 'resources', 'sounds', filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 데이터 경로 (DB, 로그 등).
|
||||
*/
|
||||
export function getUserDataPath(): string {
|
||||
return app.getPath('userData')
|
||||
}
|
||||
|
|
@ -42,6 +42,9 @@ export function createMainWindow(): BrowserWindow {
|
|||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
if (is.dev) {
|
||||
mainWindow?.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
logger.info('Main window shown')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
// src/renderer/App.tsx — 루트 컴포넌트
|
||||
// 테마 시스템: auto(시스템) / dark / light. 기본은 auto → 다크.
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
|
||||
import { lightTheme, darkTheme } from './theme'
|
||||
import { getTheme } from './theme'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import type { ThemeMode } from '@shared/types'
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const [themeMode] = useState<ThemeMode>('auto')
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
|
||||
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
|
||||
const theme = useMemo(() => {
|
||||
if (themeMode === 'auto') {
|
||||
return prefersDark ? darkTheme : lightTheme
|
||||
}
|
||||
return themeMode === 'dark' ? darkTheme : lightTheme
|
||||
}, [themeMode, prefersDark])
|
||||
// 설정에서 테마 로드
|
||||
useEffect(() => {
|
||||
window.electronAPI.config.getTheme().then((result) => {
|
||||
if (result.success) {
|
||||
setThemeMode(result.data)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const theme = useMemo(() => getTheme(themeMode, prefersDark), [themeMode, prefersDark])
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// src/renderer/components/AppLayout.tsx
|
||||
// 08-design-system.md SSOT 적용. 앰버 악센트, LED, 다크 카드.
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
|
|
@ -9,14 +10,14 @@ import {
|
|||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Typography,
|
||||
Chip
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import { DashboardPage } from '../pages/DashboardPage'
|
||||
import { HistoryPage } from '../pages/HistoryPage'
|
||||
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||
|
|
@ -41,73 +42,139 @@ export function AppLayout(): React.ReactElement {
|
|||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Sidebar Drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Sidebar Drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
|
||||
D3RO Voice
|
||||
</Typography>
|
||||
<Chip label="v1.0" size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Navigation */}
|
||||
<List sx={{ flex: 1, pt: 1 }}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.route}
|
||||
selected={currentRoute === item.route}
|
||||
onClick={() => setCurrentRoute(item.route)}
|
||||
sx={{ my: 0.5 }}
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header — 앰버 악센트 로고 */}
|
||||
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{/* LED indicator */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
boxShadow: `0 0 6px ${d3roPalette.accent.amberGlow}, 0 0 16px ${d3roPalette.accent.amberDim}`,
|
||||
animation: 'led-pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes led-pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
},
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '14px',
|
||||
letterSpacing: '0.05em',
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
D3RO VOICE
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '10px',
|
||||
color: d3roPalette.text.label,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
v1.0
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* Navigation label */}
|
||||
<Typography
|
||||
sx={{
|
||||
px: 2.5,
|
||||
pt: 2,
|
||||
pb: 1,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: d3roPalette.text.label,
|
||||
}}
|
||||
>
|
||||
Navigation
|
||||
</Typography>
|
||||
|
||||
<List sx={{ flex: 1, pt: 0 }}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.route}
|
||||
selected={currentRoute === item.route}
|
||||
onClick={() => setCurrentRoute(item.route)}
|
||||
sx={{ my: 0.5 }}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 36,
|
||||
color: currentRoute === item.route
|
||||
? d3roPalette.accent.amber
|
||||
: d3roPalette.text.label,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '14px',
|
||||
fontWeight: currentRoute === item.route ? 600 : 400,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* Bottom settings */}
|
||||
<List sx={{ pb: 1 }}>
|
||||
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.label }}>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Settings"
|
||||
primaryTypographyProps={{ fontSize: '14px' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</List>
|
||||
</Drawer>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Bottom */}
|
||||
<List>
|
||||
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Drawer>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
overflow: 'auto',
|
||||
bgcolor: 'background.default'
|
||||
}}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
{/* Content Area */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
}}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<StatusBar />
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
FormControl
|
||||
} from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { ThemeMode, AppConfig } from '@shared/types'
|
||||
|
||||
interface SettingsModalProps {
|
||||
|
|
@ -66,13 +67,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
||||
Settings
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
<DialogContent>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||
<Tab label="General" />
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
// src/renderer/components/StatusBar.tsx
|
||||
// 하단 상태 표시: Ollama 연결 상태
|
||||
// 하단 상태 표시: LED 인디케이터 + 태그 시스템. 08-design-system.md SSOT.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Chip } from '@mui/material'
|
||||
import CircleIcon from '@mui/icons-material/Circle'
|
||||
import { Box, Typography, Chip } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { LLMStatus } from '@shared/types'
|
||||
|
||||
function Led({ active, color }: { active: boolean; color?: string }): React.ReactElement {
|
||||
const c = color ?? d3roPalette.tag.green
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: active ? c : d3roPalette.text.disabled,
|
||||
boxShadow: active ? `0 0 4px ${c}, 0 0 8px ${c}40` : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.3s ease, box-shadow 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 상태 조회
|
||||
window.electronAPI.llm.getStatus().then((result) => {
|
||||
if (result.success) setLlmStatus(result.data)
|
||||
})
|
||||
|
||||
// 상태 변경 구독
|
||||
const unsub = window.electronAPI.llm.onStatusChanged((event) => {
|
||||
setLlmStatus(event.status)
|
||||
})
|
||||
|
||||
// 5초마다 폴링 (main에서 이벤트를 보내지 않을 수 있으므로)
|
||||
const interval = setInterval(() => {
|
||||
window.electronAPI.llm.getStatus().then((result) => {
|
||||
if (result.success) setLlmStatus(result.data)
|
||||
|
|
@ -40,30 +54,53 @@ export function StatusBar(): React.ReactElement {
|
|||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper'
|
||||
py: 0.75,
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: 'background.paper',
|
||||
minHeight: 32,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={<CircleIcon sx={{ fontSize: 8 }} />}
|
||||
label={connected ? 'Ollama Connected' : 'Ollama Offline'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={connected ? 'success' : 'default'}
|
||||
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
||||
/>
|
||||
{/* Ollama 상태 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led active={connected} color={connected ? d3roPalette.tag.green : d3roPalette.tag.red} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
color: d3roPalette.text.secondary,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{connected ? 'OLLAMA' : 'OFFLINE'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 활성 모델 태그 */}
|
||||
{llmStatus?.activeModel && (
|
||||
<Chip
|
||||
label={llmStatus.activeModel}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
||||
color="primary"
|
||||
sx={{ height: 20, '& .MuiChip-label': { px: 1, fontSize: '10px' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 스페이서 */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* 핫키 힌트 */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '10px',
|
||||
color: d3roPalette.text.label,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
RIGHT ALT — DICTATE
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,16 @@
|
|||
// src/renderer/pages/CommandsPage.tsx
|
||||
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent
|
||||
Box, Typography, Button, IconButton, Chip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
TextField, Card, CardContent
|
||||
} from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { IPCResult } from '@shared/errors'
|
||||
|
||||
interface CustomInstruction {
|
||||
|
|
@ -44,37 +34,21 @@ export function CommandsPage(): React.ReactElement {
|
|||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result: IPCResult<CustomInstruction[]> = await window.electronAPI.system
|
||||
.getPlatform()
|
||||
.then(() =>
|
||||
(window as Record<string, unknown>).electronAPI as Record<string, unknown>
|
||||
)
|
||||
.catch(() => null) as unknown as IPCResult<CustomInstruction[]>
|
||||
|
||||
// instruction IPC를 직접 invoke
|
||||
try {
|
||||
const ipcResult = await (window.electronAPI as Record<string, unknown> & {
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<IPCResult<CustomInstruction[]>>
|
||||
}).invoke?.('instruction:getAll') as unknown as IPCResult<CustomInstruction[]> | undefined
|
||||
|
||||
// fallback: window.electronAPI에 instruction이 아직 없으므로 ipcRenderer 직접 호출
|
||||
const { ipcRenderer } = window as unknown as { ipcRenderer?: { invoke: (ch: string) => Promise<IPCResult<CustomInstruction[]>> } }
|
||||
if (ipcRenderer) {
|
||||
const r = await ipcRenderer.invoke('instruction:getAll')
|
||||
if (r.success) setInstructions(r.data)
|
||||
} else if (ipcResult && ipcResult.success) {
|
||||
if (ipcResult && ipcResult.success) {
|
||||
setInstructions(ipcResult.data)
|
||||
}
|
||||
} catch {
|
||||
// Phase 6에서는 preload에 instruction이 추가되어야 하지만,
|
||||
// 현재 세션에서 빠르게 처리하기 위해 빈 배열로 시작
|
||||
// preload에 instruction API가 없을 수 있음
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const openAdd = () => {
|
||||
setEditId(null)
|
||||
|
|
@ -94,17 +68,20 @@ export function CommandsPage(): React.ReactElement {
|
|||
|
||||
const handleSave = async () => {
|
||||
setDialogOpen(false)
|
||||
// TODO: IPC 호출로 저장
|
||||
loadData()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Custom Commands
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>Commands</Typography>
|
||||
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||
Custom LLM instructions
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>
|
||||
Add Command
|
||||
</Button>
|
||||
</Box>
|
||||
|
|
@ -113,87 +90,66 @@ export function CommandsPage(): React.ReactElement {
|
|||
<Typography color="text.secondary">Loading...</Typography>
|
||||
) : instructions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
Commands will be available after the service initializes.
|
||||
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
Built-in commands: Translate, Summarize, Formal Rewrite, Code Explain, Free Prompt.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<List>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{instructions.map((inst) => (
|
||||
<ListItem
|
||||
key={inst.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => openEdit(inst)}>
|
||||
<Card key={inst.id} sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 }, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '14px', fontWeight: 600 }}>
|
||||
{inst.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={inst.isBuiltin ? 'BUILT-IN' : 'CUSTOM'}
|
||||
size="small"
|
||||
color={inst.isBuiltin ? 'secondary' : 'primary'}
|
||||
/>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '12px', color: 'text.secondary', mt: 0.5 }}>
|
||||
{inst.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEdit(inst)}
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{!inst.isBuiltin && (
|
||||
<IconButton size="small">
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={inst.name}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{inst.description}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={inst.isBuiltin ? 'Built-in' : 'Custom'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={inst.isBuiltin ? 'default' : 'primary'}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{editId ? 'Edit Command' : 'Add Command'}</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? 'Edit Command' : 'Add Command'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Prompt Template"
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
sx={{ mt: 2 }}
|
||||
helperText="Use {{text}} for the transcribed text"
|
||||
/>
|
||||
<TextField label="Name" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label="Description" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
<TextField label="Prompt Template" value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="Use {{text}} for transcribed text" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)} color="secondary" variant="contained">Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,96 @@
|
|||
// src/renderer/pages/DashboardPage.tsx
|
||||
// 08-design-system.md 3.7 Dashboard 레이아웃.
|
||||
// hero 수치, 카드 그리드, StatusPanel, 태그 시스템.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
||||
import { Box, Card, CardContent, Typography, Chip } from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import TimerIcon from '@mui/icons-material/Timer'
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
||||
import TodayIcon from '@mui/icons-material/Today'
|
||||
import WhatshotIcon from '@mui/icons-material/Whatshot'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import type { StatsSummary } from '@shared/types'
|
||||
|
||||
// ── StatCard 컴포넌트 ────────────────────────────────────
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
icon: React.ReactElement
|
||||
tag?: { text: string; color: 'primary' | 'success' | 'warning' | 'error' }
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
|
||||
function StatCard({ label, value, icon, tag }: StatCardProps): React.ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Box sx={{ color: 'primary.main' }}>{icon}</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{title}
|
||||
<Card sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||
{/* Label row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: d3roPalette.text.label,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{tag && (
|
||||
<Chip label={tag.text} color={tag.color} size="small" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Hero value */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||||
<Box sx={{ color: d3roPalette.accent.amber, opacity: 0.8 }}>{icon}</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
color: d3roPalette.text.primary,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── LED 인디케이터 ───────────────────────────────────────
|
||||
|
||||
function Led({ status }: { status: 'active' | 'warning' | 'error' | 'off' }): React.ReactElement {
|
||||
const colors = {
|
||||
active: { bg: d3roPalette.tag.green, shadow: d3roPalette.tag.green },
|
||||
warning: { bg: d3roPalette.tag.orange, shadow: d3roPalette.tag.orange },
|
||||
error: { bg: d3roPalette.tag.red, shadow: d3roPalette.tag.red },
|
||||
off: { bg: d3roPalette.text.disabled, shadow: 'transparent' },
|
||||
}
|
||||
const c = colors[status]
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.bg,
|
||||
boxShadow: status !== 'off' ? `0 0 6px ${c.shadow}, 0 0 12px ${c.shadow}40` : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 유틸 ─────────────────────────────────────────────────
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const hours = Math.floor(totalSec / 3600)
|
||||
|
|
@ -39,15 +100,21 @@ function formatTime(ms: number): string {
|
|||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ── DashboardPage ────────────────────────────────────────
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [ollamaConnected, setOllamaConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.stats.getSummary().then((result) => {
|
||||
if (result.success) setStats(result.data)
|
||||
})
|
||||
|
||||
// 30초마다 갱신
|
||||
window.electronAPI.llm.getStatus().then((result) => {
|
||||
if (result.success) setOllamaConnected(result.data.connectionState === 'connected')
|
||||
})
|
||||
|
||||
const interval = setInterval(() => {
|
||||
window.electronAPI.stats.getSummary().then((result) => {
|
||||
if (result.success) setStats(result.data)
|
||||
|
|
@ -58,73 +125,138 @@ export function DashboardPage(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Dashboard
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '22px',
|
||||
fontWeight: 700,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '14px',
|
||||
color: d3roPalette.text.secondary,
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
Voice assistant overview
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Status Panel (서비스 상태) */}
|
||||
<Card sx={{ mb: 3, p: 0 }}>
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led status="active" />
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||
STT Ready
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led status={ollamaConnected ? 'active' : 'warning'} />
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||
{ollamaConnected ? 'Ollama Connected' : 'Ollama Offline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led status="active" />
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary }}>
|
||||
Hotkey Active
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stat Cards Grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: 3,
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<StatCard
|
||||
label="Total Sessions"
|
||||
value={String(stats?.totalSessionCount ?? 0)}
|
||||
icon={<MicIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Time"
|
||||
value={formatTime(stats?.totalRecordingTimeMs ?? 0)}
|
||||
icon={<TimerIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Words"
|
||||
value={String(stats?.totalWordCount ?? 0)}
|
||||
icon={<TextFieldsIcon />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Streak"
|
||||
value={`${stats?.streakDays ?? 0}d`}
|
||||
icon={<WhatshotIcon />}
|
||||
tag={stats?.streakDays && stats.streakDays > 0 ? { text: 'ACTIVE', color: 'success' } : undefined}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Today Section */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: d3roPalette.text.label,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Total Sessions"
|
||||
value={String(stats?.totalSessionCount ?? 0)}
|
||||
icon={<MicIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Total Time"
|
||||
value={formatTime(stats?.totalRecordingTimeMs ?? 0)}
|
||||
icon={<TimerIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Total Words"
|
||||
value={String(stats?.totalWordCount ?? 0)}
|
||||
icon={<TextFieldsIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title="Streak"
|
||||
value={`${stats?.streakDays ?? 0} days`}
|
||||
icon={<TodayIcon />}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Today's stats */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Today
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">Sessions</Typography>
|
||||
<Typography variant="h5">{stats?.todaySessionCount ?? 0}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">Time</Typography>
|
||||
<Typography variant="h5">{formatTime(stats?.todayRecordingTimeMs ?? 0)}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary">Words</Typography>
|
||||
<Typography variant="h5">{stats?.todayWordCount ?? 0}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Card sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||
<Typography sx={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: d3roPalette.text.label, mb: 1 }}>
|
||||
Sessions
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '28px', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{stats?.todaySessionCount ?? 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||
<Typography sx={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: d3roPalette.text.label, mb: 1 }}>
|
||||
Time
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '28px', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatTime(stats?.todayRecordingTimeMs ?? 0)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
|
||||
<Typography sx={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase', color: d3roPalette.text.label, mb: 1 }}>
|
||||
Words
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '28px', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{stats?.todayWordCount ?? 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,26 @@
|
|||
// src/renderer/pages/DictionaryPage.tsx
|
||||
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Card,
|
||||
CardContent,
|
||||
InputAdornment
|
||||
Box, Typography, TextField, Button, IconButton, Chip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Card, CardContent, InputAdornment
|
||||
} from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const CATEGORY_COLOR: Record<string, 'primary' | 'secondary' | 'warning'> = {
|
||||
user: 'primary',
|
||||
auto: 'warning',
|
||||
technical: 'secondary',
|
||||
}
|
||||
|
||||
export function DictionaryPage(): React.ReactElement {
|
||||
const [data, setData] = useState<DictPageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -39,16 +34,11 @@ export function DictionaryPage(): React.ReactElement {
|
|||
const result = search.trim()
|
||||
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
}
|
||||
if (result.success) setData(result.data)
|
||||
setLoading(false)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newWord.trim()) return
|
||||
|
|
@ -68,32 +58,32 @@ export function DictionaryPage(): React.ReactElement {
|
|||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Dictionary
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setAddOpen(true)}
|
||||
size="small"
|
||||
>
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>Dictionary</Typography>
|
||||
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||
Custom words for better STT accuracy
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>
|
||||
Add Word
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Search */}
|
||||
<TextField
|
||||
placeholder="Search words..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
sx={{ mb: 3 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
<SearchIcon sx={{ color: d3roPalette.text.label }} />
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
|
|
@ -104,46 +94,51 @@ export function DictionaryPage(): React.ReactElement {
|
|||
<Typography color="text.secondary">Loading...</Typography>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
{search ? 'No words found.' : 'No words yet. Add custom words for better STT accuracy.'}
|
||||
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
{search ? 'No words found.' : 'No words yet. Add custom words to improve recognition.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<List>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{data.entries.map((entry: DictionaryEntry) => (
|
||||
<ListItem
|
||||
key={entry.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={entry.word}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Card key={entry.id} sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 }, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '14px', fontWeight: 600 }}>
|
||||
{entry.word}
|
||||
</Typography>
|
||||
{entry.pronunciation && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.text.label }}>
|
||||
[{entry.pronunciation}]
|
||||
</Typography>
|
||||
)}
|
||||
<Chip label={entry.category} size="small" variant="outlined" />
|
||||
<Chip label={`used ${entry.usageCount}x`} size="small" variant="outlined" />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Chip label={entry.category.toUpperCase()} size="small" color={CATEGORY_COLOR[entry.category] ?? 'primary'} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.label, alignSelf: 'center' }}>
|
||||
{entry.usageCount}× used
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Add Word Dialog */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add Word</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Add Word</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="Word"
|
||||
|
|
@ -161,11 +156,9 @@ export function DictionaryPage(): React.ReactElement {
|
|||
sx={{ mt: 2 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setAddOpen(false)} color="secondary" variant="contained">Cancel</Button>
|
||||
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>Add</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
// src/renderer/pages/HistoryPage.tsx
|
||||
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Chip,
|
||||
Pagination,
|
||||
|
|
@ -18,10 +16,29 @@ import {
|
|||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleString('ko-KR', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.round(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const MODE_TAG: Record<string, 'primary' | 'secondary' | 'warning'> = {
|
||||
dictation: 'primary',
|
||||
translate: 'secondary',
|
||||
command: 'warning',
|
||||
}
|
||||
|
||||
export function HistoryPage(): React.ReactElement {
|
||||
const [data, setData] = useState<HistoryPageData | null>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
|
|
@ -33,16 +50,11 @@ export function HistoryPage(): React.ReactElement {
|
|||
const result = search.trim()
|
||||
? await window.electronAPI.history.search({ query: search, page, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE })
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
}
|
||||
if (result.success) setData(result.data)
|
||||
setLoading(false)
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await window.electronAPI.history.delete({ id })
|
||||
|
|
@ -53,41 +65,28 @@ export function HistoryPage(): React.ReactElement {
|
|||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
return new Date(ts).toLocaleString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.round(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
History
|
||||
</Typography>
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>History</Typography>
|
||||
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
|
||||
Transcription history
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Search */}
|
||||
<TextField
|
||||
placeholder="Search transcriptions..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
sx={{ mb: 3 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
<SearchIcon sx={{ color: d3roPalette.text.label }} />
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
|
|
@ -98,59 +97,90 @@ export function HistoryPage(): React.ReactElement {
|
|||
<Typography color="text.secondary">Loading...</Typography>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
{search ? 'No results found.' : 'No history yet.'}
|
||||
<CardContent sx={{ py: 6, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">
|
||||
{search ? 'No results found.' : 'No history yet. Start recording!'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<List>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{data.entries.map((entry: HistoryEntry) => (
|
||||
<ListItem
|
||||
key={entry.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={entry.polishedText || entry.originalText}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(entry.createdAt)}
|
||||
<Card key={entry.id} sx={{ p: 0 }}>
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
{/* Text */}
|
||||
<Box sx={{ flex: 1, mr: 2 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.5,
|
||||
color: 'text.primary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{entry.polishedText || entry.originalText}
|
||||
</Typography>
|
||||
<Chip label={formatDuration(entry.duration)} size="small" variant="outlined" />
|
||||
{entry.detectedLanguage && (
|
||||
<Chip label={entry.detectedLanguage} size="small" variant="outlined" />
|
||||
)}
|
||||
<Chip label={entry.mode} size="small" variant="outlined" />
|
||||
|
||||
{/* Meta row */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
color: d3roPalette.text.label,
|
||||
}}
|
||||
>
|
||||
{formatDate(entry.createdAt)}
|
||||
</Typography>
|
||||
<Chip label={formatDuration(entry.duration)} size="small" color="primary" />
|
||||
{entry.detectedLanguage && (
|
||||
<Chip label={entry.detectedLanguage.toUpperCase()} size="small" color="secondary" />
|
||||
)}
|
||||
<Chip label={entry.mode.toUpperCase()} size="small" color={MODE_TAG[entry.mode] ?? 'primary'} />
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ sx: { pr: 8 } }}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||
<Pagination
|
||||
count={data.totalPages}
|
||||
page={page + 1}
|
||||
onChange={(_, p) => setPage(p - 1)}
|
||||
sx={{
|
||||
'& .Mui-selected': {
|
||||
bgcolor: `${d3roPalette.accent.amberDim} !important`,
|
||||
color: d3roPalette.accent.amber,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,148 +1,192 @@
|
|||
// src/renderer/theme.ts — MUI 7 테마 정의 (설계서 03 기반)
|
||||
// src/renderer/theme.ts
|
||||
// 08-design-system.md SSOT 기반 MUI 테마.
|
||||
// D3RO 다크(기본) + 라이트 + auto(시스템). 나중에 커스텀 테마 추가 가능.
|
||||
|
||||
import { createTheme, type ThemeOptions } from '@mui/material/styles'
|
||||
import { createTheme, type Theme } from '@mui/material/styles'
|
||||
|
||||
const commonOptions: ThemeOptions = {
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif'
|
||||
].join(','),
|
||||
h4: { fontWeight: 600, fontSize: '1.5rem' },
|
||||
h5: { fontWeight: 600, fontSize: '1.25rem' },
|
||||
h6: { fontWeight: 600, fontSize: '1rem' },
|
||||
subtitle1: { fontWeight: 500 },
|
||||
body1: { fontSize: '0.9375rem' },
|
||||
body2: { fontSize: '0.8125rem' },
|
||||
button: { textTransform: 'none' as const, fontWeight: 500 }
|
||||
// ── SSOT: 디자인 시스템 팔레트 상수 ───────────────────────
|
||||
export const d3roPalette = {
|
||||
bg: {
|
||||
app: '#19191b',
|
||||
card: '#242427',
|
||||
cardHover: '#2a2a2d',
|
||||
elevated: '#2e2e32',
|
||||
input: '#1e1e21',
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
accent: {
|
||||
amber: '#f25b29',
|
||||
amberDim: 'rgba(242, 91, 41, 0.15)',
|
||||
amberGlow: 'rgba(242, 91, 41, 0.6)',
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableElevation: true
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderRadius: 8,
|
||||
padding: '8px 16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: {
|
||||
elevation: 0
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
border: '1px solid'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
width: 240,
|
||||
borderRight: 'none'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
marginLeft: 8,
|
||||
marginRight: 8
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
variant: 'outlined'
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 6,
|
||||
fontWeight: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tag: {
|
||||
purple: '#b854f5',
|
||||
purpleBg: 'rgba(184, 84, 245, 0.12)',
|
||||
orange: '#f59e0b',
|
||||
orangeBg: 'rgba(245, 158, 11, 0.12)',
|
||||
red: '#ef4444',
|
||||
redBg: 'rgba(239, 68, 68, 0.12)',
|
||||
green: '#22c55e',
|
||||
greenBg: 'rgba(34, 197, 94, 0.12)',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#8e8e93',
|
||||
label: '#7c7c82',
|
||||
disabled: '#4a4a4e',
|
||||
},
|
||||
border: {
|
||||
subtle: 'rgba(255, 255, 255, 0.04)',
|
||||
default: 'rgba(255, 255, 255, 0.08)',
|
||||
strong: 'rgba(255, 255, 255, 0.12)',
|
||||
},
|
||||
crt: {
|
||||
phosphor: '#f25b29',
|
||||
phosphorDim: '#c44a22',
|
||||
scanline: 'rgba(0, 0, 0, 0.15)',
|
||||
bg: '#242528',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const d3roFontSans = [
|
||||
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
|
||||
'"Helvetica Neue"', 'Arial', 'sans-serif',
|
||||
].join(',')
|
||||
|
||||
export const d3roFontMono = [
|
||||
'ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Menlo', 'Consolas',
|
||||
'"Liberation Mono"', 'monospace',
|
||||
].join(',')
|
||||
|
||||
// ── 모드별 변동 팔레트 ────────────────────────────────────
|
||||
interface ModePalette {
|
||||
bg: { app: string; card: string; cardHover: string; elevated: string; input: string }
|
||||
text: { primary: string; secondary: string; label: string; disabled: string }
|
||||
border: { subtle: string; default: string; strong: string }
|
||||
}
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
...commonOptions,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: 'rgb(31, 93, 242)',
|
||||
light: 'rgb(71, 133, 255)',
|
||||
dark: 'rgb(20, 65, 180)',
|
||||
contrastText: '#FFFFFF'
|
||||
},
|
||||
secondary: {
|
||||
main: 'rgb(108, 117, 125)',
|
||||
light: 'rgb(173, 181, 189)',
|
||||
dark: 'rgb(73, 80, 87)'
|
||||
},
|
||||
background: {
|
||||
default: '#F9F9F9',
|
||||
paper: '#FFFFFF'
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(0, 0, 0, 0.87)',
|
||||
secondary: 'rgba(0, 0, 0, 0.6)'
|
||||
},
|
||||
divider: 'rgba(0, 0, 0, 0.08)',
|
||||
error: { main: '#D32F2F' },
|
||||
success: { main: '#2E7D32' },
|
||||
warning: { main: '#ED6C02' }
|
||||
}
|
||||
})
|
||||
const darkPalette: ModePalette = {
|
||||
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21' },
|
||||
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e' },
|
||||
border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' },
|
||||
}
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
...commonOptions,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: 'rgb(71, 133, 255)',
|
||||
light: 'rgb(120, 170, 255)',
|
||||
dark: 'rgb(31, 93, 242)',
|
||||
contrastText: '#FFFFFF'
|
||||
},
|
||||
secondary: {
|
||||
main: 'rgb(173, 181, 189)',
|
||||
light: 'rgb(206, 212, 218)',
|
||||
dark: 'rgb(108, 117, 125)'
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1E1E1E'
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(255, 255, 255, 0.87)',
|
||||
secondary: 'rgba(255, 255, 255, 0.6)'
|
||||
},
|
||||
divider: 'rgba(255, 255, 255, 0.08)',
|
||||
error: { main: '#EF5350' },
|
||||
success: { main: '#4CAF50' },
|
||||
warning: { main: '#FFA726' }
|
||||
}
|
||||
})
|
||||
const lightPalette: ModePalette = {
|
||||
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff' },
|
||||
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc' },
|
||||
border: { subtle: 'rgba(0,0,0,0.04)', default: 'rgba(0,0,0,0.08)', strong: 'rgba(0,0,0,0.12)' },
|
||||
}
|
||||
|
||||
export function getTheme(mode: 'light' | 'dark') {
|
||||
// ── 테마 팩토리 ───────────────────────────────────────────
|
||||
function createD3ROTheme(mode: 'dark' | 'light'): Theme {
|
||||
const isDark = mode === 'dark'
|
||||
const p = isDark ? darkPalette : lightPalette
|
||||
const accent = d3roPalette.accent
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: { main: accent.amber, light: '#ff7a4d', dark: '#c44a22', contrastText: '#fff' },
|
||||
secondary: { main: d3roPalette.tag.purple, light: '#d084ff', dark: '#8a3cc4' },
|
||||
error: { main: d3roPalette.tag.red },
|
||||
warning: { main: d3roPalette.tag.orange },
|
||||
success: { main: d3roPalette.tag.green },
|
||||
background: { default: p.bg.app, paper: p.bg.card },
|
||||
text: { primary: p.text.primary, secondary: p.text.secondary, disabled: p.text.disabled },
|
||||
divider: p.border.default,
|
||||
},
|
||||
typography: {
|
||||
fontFamily: d3roFontSans,
|
||||
h4: { fontWeight: 700, fontSize: '22px', lineHeight: 1.3 },
|
||||
h5: { fontWeight: 700, fontSize: '18px', lineHeight: 1.4 },
|
||||
h6: { fontWeight: 600, fontSize: '14px', lineHeight: 1.5 },
|
||||
subtitle1: { fontWeight: 500, fontSize: '18px', lineHeight: 1.4 },
|
||||
body1: { fontSize: '14px', lineHeight: 1.5 },
|
||||
body2: { fontSize: '12px', lineHeight: 1.4 },
|
||||
button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '14px' },
|
||||
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: p.text.label },
|
||||
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
|
||||
},
|
||||
shape: { borderRadius: 22 },
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: { body: { backgroundColor: p.bg.app, color: p.text.primary } },
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: { disableElevation: true },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none', fontWeight: 600, borderRadius: 10, padding: '10px 20px',
|
||||
transition: 'transform 0.05s linear, box-shadow 0.05s linear',
|
||||
boxShadow: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||||
'&:active': { transform: 'translateY(2px)', boxShadow: '0 0 0 rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)' },
|
||||
},
|
||||
containedPrimary: { '&:hover': { backgroundColor: '#d94f24' } },
|
||||
containedSecondary: { backgroundColor: p.bg.elevated, color: p.text.primary, '&:hover': { backgroundColor: isDark ? '#353539' : '#e5e5e7' } },
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: { elevation: 0 },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: p.bg.card, borderRadius: 22,
|
||||
borderTop: `1px solid ${p.border.subtle}`,
|
||||
boxShadow: isDark ? '0 8px 30px rgba(0,0,0,0.3)' : '0 4px 20px rgba(0,0,0,0.06)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&:hover': { backgroundColor: p.bg.cardHover },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 999, fontWeight: 700, fontSize: '11px', letterSpacing: '0.1em', textTransform: 'uppercase', height: 24 },
|
||||
colorPrimary: { backgroundColor: accent.amberDim, color: accent.amber },
|
||||
colorSecondary: { backgroundColor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple },
|
||||
colorSuccess: { backgroundColor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green },
|
||||
colorError: { backgroundColor: d3roPalette.tag.redBg, color: d3roPalette.tag.red },
|
||||
colorWarning: { backgroundColor: d3roPalette.tag.orangeBg, color: d3roPalette.tag.orange },
|
||||
},
|
||||
},
|
||||
MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: p.bg.app, borderRight: `1px solid ${p.border.subtle}` } } },
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 10, marginLeft: 8, marginRight: 8,
|
||||
'&.Mui-selected': { backgroundColor: accent.amberDim, color: accent.amber, fontWeight: 600, '&:hover': { backgroundColor: 'rgba(242,91,41,0.2)' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: { styleOverrides: { paper: { backgroundColor: p.bg.card, borderRadius: 22, border: `1px solid ${p.border.subtle}`, boxShadow: '0 16px 48px rgba(0,0,0,0.5)' } } },
|
||||
MuiTextField: {
|
||||
defaultProps: { size: 'small', variant: 'outlined' },
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: p.bg.input, borderRadius: 10,
|
||||
'& fieldset': { borderColor: p.border.default },
|
||||
'&:hover fieldset': { borderColor: p.border.strong },
|
||||
'&.Mui-focused fieldset': { borderColor: accent.amber },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTooltip: { defaultProps: { arrow: true }, styleOverrides: { tooltip: { backgroundColor: p.bg.elevated, fontSize: '12px', borderRadius: 8, border: `1px solid ${p.border.subtle}` } } },
|
||||
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accent.amber } } },
|
||||
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accent.amber, fontWeight: 600 } } } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────
|
||||
export const darkTheme = createD3ROTheme('dark')
|
||||
export const lightTheme = createD3ROTheme('light')
|
||||
|
||||
/** 테마 모드에 따라 Theme 반환. auto일 때는 prefersDark 파라미터 사용. */
|
||||
export function getTheme(mode: 'dark' | 'light' | 'auto', prefersDark = true): Theme {
|
||||
if (mode === 'auto') return prefersDark ? darkTheme : lightTheme
|
||||
return mode === 'dark' ? darkTheme : lightTheme
|
||||
}
|
||||
|
||||
/** 현재 모드의 ModePalette 가져오기 (컴포넌트에서 직접 참조용) */
|
||||
export function getModePalette(mode: 'dark' | 'light'): ModePalette {
|
||||
return mode === 'dark' ? darkPalette : lightPalette
|
||||
}
|
||||
|
|
|
|||
87
tests/helpers/createTestDb.ts
Normal file
87
tests/helpers/createTestDb.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// tests/helpers/createTestDb.ts
|
||||
// in-memory SQLite + drizzle-orm 스키마 적용
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from '../../src/main/db/schema'
|
||||
|
||||
/**
|
||||
* 테스트용 in-memory SQLite DB를 생성한다.
|
||||
* 각 테스트에서 독립적인 DB를 사용할 수 있다.
|
||||
*/
|
||||
export function createTestDb(): {
|
||||
db: BetterSQLite3Database<typeof schema>
|
||||
sqlite: Database.Database
|
||||
close: () => void
|
||||
} {
|
||||
const sqlite = new Database(':memory:')
|
||||
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
|
||||
// 테이블 생성 (src/main/db/index.ts의 SQL과 동일)
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
original_text TEXT NOT NULL,
|
||||
polished_text TEXT,
|
||||
focused_app TEXT,
|
||||
focused_app_name TEXT,
|
||||
focused_app_window_title TEXT,
|
||||
mode TEXT NOT NULL DEFAULT 'dictation',
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
error_code TEXT,
|
||||
audio_local_path TEXT,
|
||||
duration REAL NOT NULL,
|
||||
detected_language TEXT,
|
||||
mic_device TEXT,
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
stt_model TEXT,
|
||||
llm_model TEXT,
|
||||
stt_latency_ms INTEGER,
|
||||
llm_latency_ms INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
app_version TEXT NOT NULL DEFAULT '1.0.0'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_status ON history(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dictionary (
|
||||
id TEXT PRIMARY KEY,
|
||||
word TEXT NOT NULL,
|
||||
pronunciation TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'user',
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category);
|
||||
CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total_duration REAL NOT NULL DEFAULT 0,
|
||||
total_words INTEGER NOT NULL DEFAULT 0,
|
||||
session_count INTEGER NOT NULL DEFAULT 0,
|
||||
streak_days INTEGER NOT NULL DEFAULT 0,
|
||||
last_session_at INTEGER,
|
||||
last_updated INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
|
||||
VALUES (1, 0, 0, 0, 0, ${Date.now()});
|
||||
`)
|
||||
|
||||
const db = drizzle(sqlite, { schema })
|
||||
|
||||
return {
|
||||
db,
|
||||
sqlite,
|
||||
close: () => sqlite.close()
|
||||
}
|
||||
}
|
||||
190
tests/main/services/CustomInstructionService.test.ts
Normal file
190
tests/main/services/CustomInstructionService.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// tests/main/services/CustomInstructionService.test.ts
|
||||
// CRUD + 프리셋 보호 + 순서 변경 테스트
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// ConfigService 모킹
|
||||
const mockStore: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => mockStore[key]),
|
||||
configSet: vi.fn((key: string, value: unknown) => {
|
||||
mockStore[key] = value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
let getCustomInstructionService: () => ReturnType<typeof import('../../../src/main/services/CustomInstructionService')['getCustomInstructionService']>
|
||||
|
||||
beforeEach(async () => {
|
||||
// 매번 모듈과 스토어 초기화
|
||||
for (const key of Object.keys(mockStore)) {
|
||||
delete mockStore[key]
|
||||
}
|
||||
vi.resetModules()
|
||||
const mod = await import('../../../src/main/services/CustomInstructionService')
|
||||
getCustomInstructionService = mod.getCustomInstructionService
|
||||
})
|
||||
|
||||
describe('CustomInstructionService', () => {
|
||||
describe('initialize + getAll', () => {
|
||||
it('첫 실행 시 5개 프리셋이 생성된다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const all = svc.getAll()
|
||||
expect(all.length).toBe(5)
|
||||
expect(all.every((i) => i.isBuiltin)).toBe(true)
|
||||
})
|
||||
|
||||
it('order 순으로 정렬하여 반환한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const all = svc.getAll()
|
||||
for (let i = 1; i < all.length; i++) {
|
||||
expect(all[i].order).toBeGreaterThanOrEqual(all[i - 1].order)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getById', () => {
|
||||
it('존재하는 ID로 명령어를 반환한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const inst = svc.getById('builtin-translate')
|
||||
expect(inst).not.toBeNull()
|
||||
expect(inst!.name).toBe('번역')
|
||||
})
|
||||
|
||||
it('없는 ID에 대해 null을 반환한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
expect(svc.getById('nonexistent')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('사용자 정의 명령어를 추가한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const created = svc.create({
|
||||
name: 'My Custom',
|
||||
description: 'Test custom instruction',
|
||||
prompt: 'Custom prompt: {{text}}',
|
||||
icon: 'Star'
|
||||
})
|
||||
|
||||
expect(created.isBuiltin).toBe(false)
|
||||
expect(created.name).toBe('My Custom')
|
||||
expect(svc.getAll().length).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('사용자 정의 명령어의 모든 필드를 수정할 수 있다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const created = svc.create({
|
||||
name: 'Original',
|
||||
description: 'Desc',
|
||||
prompt: 'Prompt',
|
||||
icon: 'Star'
|
||||
})
|
||||
|
||||
const updated = svc.update(created.id, { name: 'Updated', prompt: 'New prompt' })
|
||||
expect(updated).not.toBeNull()
|
||||
expect(updated!.name).toBe('Updated')
|
||||
expect(updated!.prompt).toBe('New prompt')
|
||||
})
|
||||
|
||||
it('프리셋은 프롬프트만 수정 가능하다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const updated = svc.update('builtin-translate', { name: 'Changed Name', prompt: 'New prompt' })
|
||||
expect(updated).not.toBeNull()
|
||||
expect(updated!.name).toBe('번역') // 이름은 변경 안 됨
|
||||
expect(updated!.prompt).toBe('New prompt') // 프롬프트는 변경됨
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('프리셋은 삭제할 수 없다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
expect(svc.delete('builtin-translate')).toBe(false)
|
||||
expect(svc.getAll().length).toBe(5)
|
||||
})
|
||||
|
||||
it('사용자 정의 명령어는 삭제할 수 있다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const created = svc.create({
|
||||
name: 'To Delete',
|
||||
description: '',
|
||||
prompt: '',
|
||||
icon: ''
|
||||
})
|
||||
|
||||
expect(svc.delete(created.id)).toBe(true)
|
||||
expect(svc.getAll().length).toBe(5)
|
||||
})
|
||||
|
||||
it('없는 ID 삭제 시 false를 반환한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
expect(svc.delete('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorder', () => {
|
||||
it('지정된 순서대로 order를 재설정한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
const all = svc.getAll()
|
||||
const reversed = [...all].reverse().map((i) => i.id)
|
||||
svc.reorder(reversed)
|
||||
|
||||
const reordered = svc.getAll()
|
||||
expect(reordered[0].id).toBe(reversed[0])
|
||||
expect(reordered[4].id).toBe(reversed[4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetBuiltins', () => {
|
||||
it('프리셋을 초기값으로 복원하고 사용자 명령어는 유지한다', () => {
|
||||
const svc = getCustomInstructionService()
|
||||
svc.initialize()
|
||||
|
||||
// 프롬프트 수정
|
||||
svc.update('builtin-translate', { prompt: 'modified prompt' })
|
||||
|
||||
// 사용자 명령어 추가
|
||||
svc.create({ name: 'User', description: '', prompt: '', icon: '' })
|
||||
|
||||
svc.resetBuiltins()
|
||||
|
||||
const all = svc.getAll()
|
||||
const translate = all.find((i) => i.id === 'builtin-translate')
|
||||
expect(translate!.prompt).toContain('{{targetLanguage}}') // 원래 프롬프트
|
||||
expect(all.length).toBe(6) // 프리셋 5 + 사용자 1
|
||||
})
|
||||
})
|
||||
})
|
||||
116
tests/main/services/DictionaryService.test.ts
Normal file
116
tests/main/services/DictionaryService.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// tests/main/services/DictionaryService.test.ts
|
||||
// DictionaryService 단위 테스트 — DB 모킹
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
function createMockQueryBuilder(data: unknown[] = []) {
|
||||
const builder: Record<string, unknown> = {}
|
||||
|
||||
builder.values = vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) }))
|
||||
builder.from = vi.fn(() => builder)
|
||||
builder.where = vi.fn(() => builder)
|
||||
builder.orderBy = vi.fn(() => builder)
|
||||
builder.limit = vi.fn(() => builder)
|
||||
builder.offset = vi.fn(() => builder)
|
||||
builder.set = vi.fn(() => builder)
|
||||
builder.get = vi.fn(() => data[0] ?? null)
|
||||
builder.all = vi.fn(() => data)
|
||||
builder.run = vi.fn(() => ({ changes: 1 }))
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
const mockDb = {
|
||||
insert: vi.fn(() => createMockQueryBuilder()),
|
||||
select: vi.fn(() => createMockQueryBuilder()),
|
||||
update: vi.fn(() => createMockQueryBuilder()),
|
||||
delete: vi.fn(() => createMockQueryBuilder())
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({
|
||||
getDatabase: () => mockDb
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
let getDictionaryService: () => ReturnType<typeof import('../../../src/main/services/DictionaryService')['getDictionaryService']>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../../../src/main/services/DictionaryService')
|
||||
getDictionaryService = mod.getDictionaryService
|
||||
})
|
||||
|
||||
describe('DictionaryService', () => {
|
||||
describe('add', () => {
|
||||
it('insert를 호출하고 엔트리를 반환한다', () => {
|
||||
const svc = getDictionaryService()
|
||||
const entry = svc.add({ word: 'AI', pronunciation: '에이아이', category: 'technical' })
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalled()
|
||||
expect(entry.id).toBeDefined()
|
||||
expect(entry.word).toBe('AI')
|
||||
expect(entry.pronunciation).toBe('에이아이')
|
||||
expect(entry.category).toBe('technical')
|
||||
expect(entry.usageCount).toBe(0)
|
||||
})
|
||||
|
||||
it('기본 카테고리는 user이다', () => {
|
||||
const svc = getDictionaryService()
|
||||
const entry = svc.add({ word: '테스트' })
|
||||
expect(entry.category).toBe('user')
|
||||
})
|
||||
|
||||
it('pronunciation 미지정 시 null이다', () => {
|
||||
const svc = getDictionaryService()
|
||||
const entry = svc.add({ word: 'test' })
|
||||
expect(entry.pronunciation).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('없는 ID에 대해 null을 반환한다', () => {
|
||||
// select().from().where().get()이 null 반환
|
||||
const svc = getDictionaryService()
|
||||
expect(svc.update({ id: 'nonexistent', word: 'test' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('delete 쿼리를 실행하고 changes > 0이면 true를 반환한다', () => {
|
||||
const svc = getDictionaryService()
|
||||
expect(svc.delete('some-id')).toBe(true)
|
||||
expect(mockDb.delete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('incrementUsage', () => {
|
||||
it('update 쿼리를 실행한다', () => {
|
||||
const svc = getDictionaryService()
|
||||
svc.incrementUsage('some-id')
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPromptHints', () => {
|
||||
it('빈 목록에서 빈 문자열을 반환한다', () => {
|
||||
const svc = getDictionaryService()
|
||||
expect(svc.getPromptHints()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('에러 없이 호출된다', () => {
|
||||
const svc = getDictionaryService()
|
||||
expect(() => svc.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
134
tests/main/services/HistoryService.test.ts
Normal file
134
tests/main/services/HistoryService.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// tests/main/services/HistoryService.test.ts
|
||||
// HistoryService 단위 테스트 — DB 계층을 모킹하여 순수 로직만 검증
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// drizzle 모킹: 체이너블 쿼리 빌더 패턴
|
||||
function createMockQueryBuilder(data: unknown[] = []) {
|
||||
const builder: Record<string, unknown> = {}
|
||||
|
||||
builder.values = vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) }))
|
||||
builder.from = vi.fn(() => builder)
|
||||
builder.where = vi.fn(() => builder)
|
||||
builder.orderBy = vi.fn(() => builder)
|
||||
builder.limit = vi.fn(() => builder)
|
||||
builder.offset = vi.fn(() => builder)
|
||||
builder.set = vi.fn(() => builder)
|
||||
builder.get = vi.fn(() => data[0] ?? null)
|
||||
builder.all = vi.fn(() => data)
|
||||
builder.run = vi.fn(() => ({ changes: 1 }))
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
const mockDb = {
|
||||
insert: vi.fn(() => createMockQueryBuilder()),
|
||||
select: vi.fn(() => createMockQueryBuilder()),
|
||||
update: vi.fn(() => createMockQueryBuilder()),
|
||||
delete: vi.fn(() => createMockQueryBuilder())
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({
|
||||
getDatabase: () => mockDb
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
let getHistoryService: () => ReturnType<typeof import('../../../src/main/services/HistoryService')['getHistoryService']>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../../../src/main/services/HistoryService')
|
||||
getHistoryService = mod.getHistoryService
|
||||
})
|
||||
|
||||
describe('HistoryService', () => {
|
||||
describe('create', () => {
|
||||
it('insert를 호출하고 엔트리를 반환한다', () => {
|
||||
const svc = getHistoryService()
|
||||
const entry = svc.create({
|
||||
originalText: '안녕하세요',
|
||||
duration: 3.5,
|
||||
wordCount: 3,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
appVersion: '1.0.0'
|
||||
})
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalled()
|
||||
expect(entry.id).toBeDefined()
|
||||
expect(entry.originalText).toBe('안녕하세요')
|
||||
expect(entry.duration).toBe(3.5)
|
||||
expect(entry.createdAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('stats 업데이트를 호출한다', () => {
|
||||
const svc = getHistoryService()
|
||||
svc.create({
|
||||
originalText: 'test',
|
||||
duration: 5.0,
|
||||
wordCount: 10,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
appVersion: '1.0.0'
|
||||
})
|
||||
|
||||
// insert 2번: history + stats update
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1)
|
||||
expect(mockDb.update).toHaveBeenCalledTimes(1) // stats update
|
||||
})
|
||||
})
|
||||
|
||||
describe('getById', () => {
|
||||
it('DB에서 조회한 결과를 반환한다', () => {
|
||||
// select().from().where().get()이 null 반환하면 null
|
||||
const svc = getHistoryService()
|
||||
const result = svc.getById('nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('delete 쿼리를 실행하고 changes > 0이면 true를 반환한다', () => {
|
||||
const svc = getHistoryService()
|
||||
const result = svc.delete('some-id')
|
||||
expect(mockDb.delete).toHaveBeenCalled()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('delete 쿼리를 실행한다', () => {
|
||||
const svc = getHistoryService()
|
||||
svc.deleteAll()
|
||||
expect(mockDb.delete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStats', () => {
|
||||
it('stats + today 쿼리를 실행한다', () => {
|
||||
const svc = getHistoryService()
|
||||
const st = svc.getStats()
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
expect(st).toHaveProperty('totalRecordingTimeMs')
|
||||
expect(st).toHaveProperty('todaySessionCount')
|
||||
expect(st).toHaveProperty('streakDays')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('에러 없이 호출된다', () => {
|
||||
const svc = getHistoryService()
|
||||
expect(() => svc.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
204
tests/main/services/VoiceModeService.test.ts
Normal file
204
tests/main/services/VoiceModeService.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// tests/main/services/VoiceModeService.test.ts
|
||||
// 상태 머신 전이 + 이중 조건 플러시 + accidentalPress 테스트
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { RecognitionState, AudioState } from '../../../src/shared/types'
|
||||
import { TIMING } from '../../../src/shared/constants'
|
||||
|
||||
// 모든 하위 서비스 모킹
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockSTT = {
|
||||
initialize: vi.fn(() => Promise.resolve()),
|
||||
transcribe: vi.fn(() =>
|
||||
Promise.resolve({ text: '테스트 전사', segments: [], language: 'ko', duration: 2, processingTime: 500 })
|
||||
),
|
||||
getStatus: vi.fn(() => ({ state: 'ready', modelId: 'base', uptime: 0 })),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => mockSTT
|
||||
}))
|
||||
|
||||
const mockAudio = {
|
||||
start: vi.fn(() => Promise.resolve()),
|
||||
stop: vi.fn(() => Promise.resolve()),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => mockAudio
|
||||
}))
|
||||
|
||||
const mockHotkey = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/services/HotkeyService', () => ({
|
||||
getHotkeyService: () => mockHotkey
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => {
|
||||
const defaults: Record<string, unknown> = {
|
||||
sttModelId: 'base',
|
||||
defaultLLMAction: 'refine',
|
||||
ollamaServerUrl: 'http://localhost:11434',
|
||||
llmModelId: 'qwen3:4b'
|
||||
}
|
||||
return defaults[key]
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTextInsert = {
|
||||
insertText: vi.fn(() => Promise.resolve({ success: true, method: 'clipboard', textLength: 10, durationMs: 50 }))
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => mockTextInsert
|
||||
}))
|
||||
|
||||
const mockLLM = {
|
||||
isAvailable: vi.fn(() => false),
|
||||
processText: vi.fn(() => Promise.resolve('다듬어진 텍스트')),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => mockLLM
|
||||
}))
|
||||
|
||||
let getVoiceModeService: () => ReturnType<typeof import('../../../src/main/services/VoiceModeService')['getVoiceModeService']>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||
getVoiceModeService = mod.getVoiceModeService
|
||||
})
|
||||
|
||||
describe('VoiceModeService', () => {
|
||||
describe('상태 머신', () => {
|
||||
it('초기 상태는 IDLE이다', () => {
|
||||
const svc = getVoiceModeService()
|
||||
const state = svc.getState()
|
||||
|
||||
expect(state.recognitionState).toBe(RecognitionState.IDLE)
|
||||
expect(state.audioState).toBe(AudioState.IDLE)
|
||||
expect(state.sessionId).toBeNull()
|
||||
})
|
||||
|
||||
it('startSession 호출 시 PREPARING으로 전이한다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const stateChanges: RecognitionState[] = []
|
||||
|
||||
svc.on('recognition-state-changed', (payload: { current: RecognitionState }) => {
|
||||
stateChanges.push(payload.current)
|
||||
})
|
||||
|
||||
await svc.startSession('dictation')
|
||||
|
||||
// PREPARING → CONNECTING → READY 순서
|
||||
expect(stateChanges[0]).toBe(RecognitionState.PREPARING)
|
||||
expect(stateChanges).toContain(RecognitionState.CONNECTING)
|
||||
})
|
||||
|
||||
it('isActive는 세션이 활성일 때 true이다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
expect(svc.isActive).toBe(false)
|
||||
|
||||
// startSession은 비동기이므로 STT init이 resolve되면 세션 활성
|
||||
const promise = svc.startSession('dictation')
|
||||
expect(svc.isActive).toBe(true) // PREPARING 상태
|
||||
|
||||
await promise
|
||||
})
|
||||
})
|
||||
|
||||
describe('accidentalPress', () => {
|
||||
it('700ms 미만 세션은 자동 취소된다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
let cancelReason: string | null = null
|
||||
|
||||
svc.on('session-cancelled', (payload: { reason: string }) => {
|
||||
cancelReason = payload.reason
|
||||
})
|
||||
|
||||
// 세션 시작 즉시 종료 (700ms 미만)
|
||||
await svc.startSession('dictation')
|
||||
await svc.stopSession()
|
||||
|
||||
expect(cancelReason).toBe('too-short')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelSession', () => {
|
||||
it('user 취소로 세션을 종료한다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
let cancelReason: string | null = null
|
||||
|
||||
svc.on('session-cancelled', (payload: { reason: string }) => {
|
||||
cancelReason = payload.reason
|
||||
})
|
||||
|
||||
await svc.startSession('dictation')
|
||||
svc.cancelSession()
|
||||
|
||||
expect(cancelReason).toBe('user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getState', () => {
|
||||
it('현재 상태를 VoiceState 형태로 반환한다', () => {
|
||||
const svc = getVoiceModeService()
|
||||
const state = svc.getState()
|
||||
|
||||
expect(state).toHaveProperty('recognitionState')
|
||||
expect(state).toHaveProperty('audioState')
|
||||
expect(state).toHaveProperty('mode')
|
||||
expect(state).toHaveProperty('sessionId')
|
||||
expect(state).toHaveProperty('recordingStartedAt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('터미널 상태', () => {
|
||||
it('cancelSession 후 _resetToIdle의 200ms 딜레이 후 IDLE로 전이한다', async () => {
|
||||
vi.useFakeTimers()
|
||||
const svc = getVoiceModeService()
|
||||
|
||||
await svc.startSession('dictation')
|
||||
svc.cancelSession()
|
||||
|
||||
// 200ms 딜레이로 IDLE 전이 예약됨
|
||||
vi.advanceTimersByTime(250)
|
||||
|
||||
const state = svc.getState()
|
||||
expect(state.recognitionState).toBe(RecognitionState.IDLE)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TIMING 상수', () => {
|
||||
it('핵심 타이밍 값이 Speakly 패턴과 일치한다', () => {
|
||||
expect(TIMING.MIN_AUDIO_DURATION).toBe(700)
|
||||
expect(TIMING.DOUBLE_PRESS_DURATION).toBe(300)
|
||||
expect(TIMING.POST_RECORDING_WAIT).toBe(4000)
|
||||
expect(TIMING.POST_RECORDING_WAIT_BUFFERED).toBe(6000)
|
||||
expect(TIMING.ABSOLUTE_MAX_WAIT).toBe(120000)
|
||||
expect(TIMING.AUDIO_LEVEL_INTERVAL).toBe(100)
|
||||
})
|
||||
})
|
||||
61
tests/setup.ts
Normal file
61
tests/setup.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// tests/setup.ts
|
||||
// vitest 글로벌 셋업: electron 모듈 모킹
|
||||
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// electron 모듈 모킹 — 테스트에서 electron import 시 에러 방지
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => ':memory:'),
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
quit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
whenReady: vi.fn(() => Promise.resolve())
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
},
|
||||
BrowserWindow: vi.fn(),
|
||||
Tray: vi.fn(),
|
||||
Menu: vi.fn(),
|
||||
nativeImage: {
|
||||
createFromPath: vi.fn()
|
||||
},
|
||||
dialog: {
|
||||
showErrorBox: vi.fn()
|
||||
},
|
||||
screen: {
|
||||
getPrimaryDisplay: vi.fn(() => ({
|
||||
workArea: { x: 0, y: 0, width: 1920, height: 1080 }
|
||||
}))
|
||||
},
|
||||
clipboard: {
|
||||
readText: vi.fn(() => ''),
|
||||
writeText: vi.fn(),
|
||||
readHTML: vi.fn(() => ''),
|
||||
readRTF: vi.fn(() => ''),
|
||||
readImage: vi.fn(() => ({ isEmpty: () => true, toPNG: () => Buffer.alloc(0) })),
|
||||
write: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// electron-log 모킹
|
||||
vi.mock('electron-log', () => {
|
||||
const noop = vi.fn()
|
||||
return {
|
||||
default: {
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
debug: noop,
|
||||
create: () => ({
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
debug: noop
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
75
tests/shared/errors.test.ts
Normal file
75
tests/shared/errors.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// tests/shared/errors.test.ts
|
||||
// D3ROError + IPCResult 헬퍼 테스트
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { D3ROError, ErrorCode, ipcSuccess, ipcError } from '../../src/shared/errors'
|
||||
|
||||
describe('D3ROError', () => {
|
||||
it('code, message, details를 올바르게 설정한다', () => {
|
||||
const err = new D3ROError(ErrorCode.STTModelNotFound, 'Model not found', { modelId: 'large' })
|
||||
|
||||
expect(err.code).toBe(ErrorCode.STTModelNotFound)
|
||||
expect(err.message).toBe('Model not found')
|
||||
expect(err.details).toEqual({ modelId: 'large' })
|
||||
expect(err.name).toBe('D3ROError')
|
||||
expect(err instanceof Error).toBe(true)
|
||||
})
|
||||
|
||||
it('toJSON으로 직렬화할 수 있다', () => {
|
||||
const err = new D3ROError(ErrorCode.LLMServerUnreachable, 'Server down')
|
||||
const json = err.toJSON()
|
||||
|
||||
expect(json.code).toBe(300)
|
||||
expect(json.message).toBe('Server down')
|
||||
expect(json.details).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fromJSON으로 역직렬화할 수 있다', () => {
|
||||
const json = { code: ErrorCode.AudioDeviceNotFound, message: 'No mic' }
|
||||
const err = D3ROError.fromJSON(json)
|
||||
|
||||
expect(err).toBeInstanceOf(D3ROError)
|
||||
expect(err.code).toBe(ErrorCode.AudioDeviceNotFound)
|
||||
expect(err.message).toBe('No mic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('IPCResult helpers', () => {
|
||||
it('ipcSuccess는 success: true + data를 반환한다', () => {
|
||||
const result = ipcSuccess({ value: 42 })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual({ value: 42 })
|
||||
}
|
||||
})
|
||||
|
||||
it('ipcError는 success: false + error를 반환한다', () => {
|
||||
const result = ipcError(ErrorCode.DBQueryFailed, 'Query failed', { table: 'history' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe(ErrorCode.DBQueryFailed)
|
||||
expect(result.error.message).toBe('Query failed')
|
||||
expect(result.error.details).toEqual({ table: 'history' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ErrorCode', () => {
|
||||
it('에러 코드 범위가 올바르다', () => {
|
||||
// STT: 100-199
|
||||
expect(ErrorCode.STTEngineNotInstalled).toBe(100)
|
||||
expect(ErrorCode.STTGPUNotAvailable).toBe(140)
|
||||
|
||||
// LLM: 300-399
|
||||
expect(ErrorCode.LLMServerUnreachable).toBe(300)
|
||||
expect(ErrorCode.LLMPromptTooLong).toBe(331)
|
||||
|
||||
// Audio: 400-499
|
||||
expect(ErrorCode.AudioDeviceNotFound).toBe(400)
|
||||
|
||||
// System: 900-999
|
||||
expect(ErrorCode.UnknownError).toBe(999)
|
||||
})
|
||||
})
|
||||
23
vitest.config.ts
Normal file
23
vitest.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
exclude: ['tests/e2e/**'],
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
testTimeout: 10000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/main/services/**'],
|
||||
exclude: ['src/main/services/index.ts']
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve(__dirname, 'src/shared')
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue