diff --git a/.claude/settings.json b/.claude/settings.json index 8925faf..8a0a5fd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,6 +14,13 @@ "Bash(which *)", "Bash(python *)", "Bash(pip *)", + "Bash(taskkill *)", + "Bash(sleep *)", + "Bash(mv *)", + "Bash(tail *)", + "Bash(head *)", + "Bash(grep *)", + "Bash(wc *)", "Edit(src/**)", "Edit(tests/**)", "Edit(docs/**)", @@ -48,7 +55,7 @@ "hooks": [ { "type": "command", - "command": "cat <<'INJECT'\n[D3RO-VOICE 강제 규칙]\n1. 구현 전 반드시 설계서 참조: docs/design/00~03 (아키텍처, 서비스 명세, IPC 타입, DB/UI)\n2. 서비스: 싱글톤 + EventEmitter. 설계서 01의 인터페이스를 그대로 구현\n3. IPC: 설계서 02의 ipc-channels.ts 채널명/타입을 그대로 사용\n4. 에러: 설계서 02의 D3ROError + ErrorCode enum 사용\n5. DB: 설계서 03의 drizzle-orm 스키마를 그대로 사용\n6. UI: 설계서 03의 컴포넌트 Props/상태/IPC 명세를 그대로 따름\n7. 윈도우: 프리로딩 + 2-phase 리사이즈 (설계서 01 WindowManagerService)\n8. 상태머신: RecognitionState + AudioState 분리 (설계서 01 VoiceModeService)\n9. 팝업: Vanilla JS, 설계서 03의 HTML/CSS/애니메이션 스펙 준수\n10. any 타입 절대 금지, console.log 절대 금지\nINJECT" + "command": "cat <<'INJECT'\n[D3RO-VOICE 강제 규칙]\n1. 구현 전 반드시 설계서 참조: docs/design/00~03 (아키텍처, 서비스 명세, IPC 타입, DB/UI)\n2. 서비스: 싱글톤 + EventEmitter. 설계서 01의 인터페이스를 그대로 구현\n3. IPC: 설계서 02의 ipc-channels.ts 채널명/타입을 그대로 사용\n4. 에러: 설계서 02의 D3ROError + ErrorCode enum 사용\n5. DB: 설계서 03의 drizzle-orm 스키마를 그대로 사용\n6. UI: 설계서 03의 컴포넌트 Props/상태/IPC 명세를 그대로 따름\n7. 윈도우: 프리로딩 + 2-phase 리사이즈 (설계서 01 WindowManagerService)\n8. 상태머신: RecognitionState + AudioState 분리 (설계서 01 VoiceModeService)\n9. 팝업: Vanilla JS, 설계서 03의 HTML/CSS/애니메이션 스펙 준수\n10. any 타입 절대 금지, console.log 절대 금지\n11. 테마 SSOT: 색상은 d3roPalette/d3roShadow(CSS var) 사용, 렌더러 tsx/ts에 하드코딩 hex 금지 (theme.ts 제외)\n12. i18n: React 컴포넌트 UI 문자열은 t() 함수 사용, 하드코딩 한국어/영어 금지\n13. 작업 완료 즉시 memory/project_status.md 갱신 의무 — 미루지 말 것\nINJECT" } ] } @@ -59,16 +66,25 @@ "hooks": [ { "type": "command", - "command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then cd 'D:/workspace/D3ROVoice' && npx tsc --noEmit 2>&1 | head -15 || true; fi" + "command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then cd 'D:/workspace/D3ROVoice' && npx tsc --noEmit 2>&1 | head -20 || true; fi" } ] }, { - "matcher": "Write", + "matcher": "Write|Edit", "hooks": [ { "type": "command", - "command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then grep -n 'any' \"$FILE\" 2>/dev/null | grep -v '// eslint-disable' | grep -v 'import' | head -5 && echo '[WARN] any 타입 발견 - 수정 필요' || true; fi" + "command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then ISSUES=''; ANY=$(grep -nE ': any[^a-zA-Z]||as any' \"$FILE\" 2>/dev/null | grep -v '// eslint' | grep -v 'import' | head -3); if [ -n \"$ANY\" ]; then ISSUES=\"any 타입: $ANY\"; fi; LOG=$(grep -n 'console\\.log' \"$FILE\" 2>/dev/null | head -3); if [ -n \"$LOG\" ]; then ISSUES=\"$ISSUES | console.log: $LOG\"; fi; if [ -n \"$ISSUES\" ]; then echo \"[HARNESS VIOLATION] $ISSUES — 즉시 수정 필요\"; fi; fi" + } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE 'src/renderer/.*\\.(tsx|ts)$' && ! echo \"$FILE\" | grep -q 'theme\\.ts' && ! echo \"$FILE\" | grep -q 'theme-vars'; then HC=$(grep -noE \"'#[0-9a-fA-F]{6}'\" \"$FILE\" 2>/dev/null | grep -v '//' | head -3); if [ -n \"$HC\" ]; then echo \"[SSOT WARN] 하드코딩 hex 색상 — d3roPalette/CSS var 사용:\"; echo \"$HC\"; fi; fi" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index d31602a..9f91ea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,9 +77,9 @@ npm run typecheck # tsc --noEmit 6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션 ## 현재 상태 -Phase: 9 완료 + 폴리싱 완료 (Phase 1~9 전부 완료) -마지막 작업: Ollama 설치 안내 UI + Settings 속도 개선 + 커맨드 선택 팝업 + 실시간 UI 갱신 -다음 작업: **Phase 10 킬러 피처** (실시간 자막, 스크린 컨텍스트, 음성 메모장, 멀티 LLM 체인, 음성 단축키) +Phase: 11 완료 (Phase 1~11 전체 완료) +마지막 작업: LicenseService + Feature Gate + LicenseModal + ProBadge + UpgradePrompt + 일일 쿼터 + LemonSqueezy API +다음 작업: Phase 12 랜딩 페이지 & 마케팅 또는 빌드/배포 안정화 차단 이슈: @nut-tree-fork/nut-js 포크 사용 ### DS 컴포넌트 현황 (src/renderer/components/ds/) @@ -88,8 +88,48 @@ Phase: 9 완료 + 폴리싱 완료 (Phase 1~9 전부 완료) - Led.tsx: LED 인디케이터 (amber/green/red/orange, pulse) ✅ - PhysicalButton.tsx: 물리 버튼 (돌출 그림자, 눌림 피드백) ✅ - MetalCard.tsx: 메탈 카드 컨테이너 ✅ -- PhosphorText.tsx: 인광 텍스트 (hero/value/label/dim) ✅ -- MetalDial.tsx: 메탈 다이얼 (동심원 텍스처, 드래그 회전, 금속 광택) ✅ +- PhosphorText.tsx: 인광 텍스트 (13종 변형: hero/title/value/heading/body/compact/small/meta/label/dim/engrave/micro/nano) ✅ +- MetalDial.tsx: 메탈 다이얼 (동심원 텍스처, 드래그 회전, conic-gradient 금속 광택) ✅ +- ScreenPanel.tsx: 글래스 스크린 패널 (CRT 없는 인셋 베젤 + 반사) ✅ +- ButtonGroup.tsx: 인셋 버튼 클러스터 (레퍼런스 .button-group) ✅ + +### 디자인 토큰 현황 (theme.ts) +- d3roTypo: 13단계 타이포 (hero 42px ~ nano 7px, size/weight/spacing/line) +- d3roShadow: 10종 그림자 (chassis/card/inset/button/dialog/tooltip 등) +- d3roRadius: 7종 반경 (outer 24px ~ pill 999px) + +### Phase 11 구현 내용 (수익화 기반) +- **LicenseService**: Free/Pro/Pro+ 3단계 티어, electron-store 대신 별도 JSON 파일, 오프라인 우선 설계 +- **Feature Gating**: Feature enum (16개 기능), 티어별 접근 매핑, 쿼터 한도 (Free: 받아쓰기 20회/일, LLM 10회/일) +- **daily_usage DB 테이블**: 날짜+기능별 사용량 추적 (drizzle-orm, UPSERT) +- **LemonSqueezy 연동**: 키 활성화/검증 API, variant_name→tier 매핑, 오프라인 유예 30일 +- **IPC**: LICENSE 네임스페이스 9개 채널 (getInfo, activate, deactivate, checkFeature, getUsage, getAllUsage, getTierComparison, upgradePrompt, tierChanged) +- **Preload**: license API 네임스페이스 추가 (9개 메서드 + 2개 이벤트 리스너) +- **ErrorCode**: 850-862 범위 라이센스 에러 (LicenseKeyInvalid, QuotaExceeded, TierRequired 등) +- **UpgradePromptModal**: 쿼터 소진/잠긴 기능 접근 시 업그레이드 유도 모달 (혜택 목록, 쿼터 바) +- **SettingsModal License 탭**: 티어 표시, 키 입력/활성화, 사용량 바, 티어 비교표 +- **DashboardPage 사용량**: Free 티어에서 오늘 사용량 바 (받아쓰기/LLM) 표시 +- **ProBadge + useProFeature**: 잠긴 기능 오버레이 래퍼 컴포넌트 + 훅 +- **VoiceModeService gating**: startSession에서 DICTATION 쿼터 체크/소비 +- **LocalLLMService gating**: processText에서 LLM_PROCESS 쿼터 체크/소비 (차단 시 원본 텍스트 폴백) +- **i18n**: license.* 네임스페이스 40+ 키 추가 (ko/en) + +### Phase 10 구현 내용 (서비스 레이어 + UI 통합) +- **MemoService**: memo_tags DB 테이블 + 태그 CRUD + 마크다운 내보내기 +- **VoiceCommandService**: 키워드→명령어 매칭 엔진, 프리셋 키워드 4종 (번역/요약/다듬기/설명) +- **ScreenContextService**: 활성 윈도우 감지(PowerShell) + 선택 텍스트 캡처(Ctrl+C) +- **ChainService**: LLM 명령어 순차 실행 파이프라인, 단계별 진행 이벤트 +- **CaptionService**: 3초 청크 연속 전사, caption-overlay 팝업(Vanilla JS) +- **VoiceModeService 통합**: 파이프라인 훅 3개 (컨텍스트 캡처→키워드 매칭→체인/컨텍스트 주입) +- **공유 컴포넌트**: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard +- **공유 유틸**: formatters.ts (formatDuration, getDateKey, formatNumber 등) +- **IPC**: 5개 새 네임스페이스 (MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION) +- **Preload**: 5개 API 네임스페이스 추가 (memo, voiceCommand, context, chain, caption) +- **HistoryPage 태그 통합**: HistoryEntryCard에 태그 표시/추가/삭제, 태그 필터 바, 마크다운 내보내기 +- **CommandsPage 키워드/체인**: VoiceCommandService 키워드 편집 섹션 + ChainService 체인 CRUD/실행 UI +- **DashboardPage 자막 토글**: 실시간 자막 시작/종료 버튼, CaptionState LED 표시 +- **SettingsModal 설정 추가**: 음성 명령어 on/off, 스크린 컨텍스트 on/off 토글 (LLM 탭) +- **i18n**: memo/voiceCommand/chain/context/dashboard.caption 번역 키 추가 (ko/en) ### Phase 9 구현 내용 - **Settings About 탭**: 앱/Electron 버전, 기술 스택, 프로젝트 링크, 정밀기기 디자인 @@ -213,6 +253,9 @@ Phase: 9 완료 + 폴리싱 완료 (Phase 1~9 전부 완료) - Phase 8: UI 디자인 재설계 + SSOT + DS 컴포넌트 정비 - Phase 9: 품질 보강 + UX 개선 (온보딩, 마이크 테스트, WAV 저장, 커맨드 팝업, Ollama 안내) - Phase 10: 킬러 피처 — Speakly를 넘어서 (실시간 자막, 스크린 컨텍스트, 음성 메모장, 멀티 LLM 체인, 음성 단축키) +- Phase 11: 수익화 기반 — LicenseService + Feature Gating + Freemium UI +- Phase 12: Pro 피처 — 파일 전사, 회의록 자동 요약, 딕테이션 템플릿 +- Phase 13: Pro+ 프리미엄 — 음성 대화 모드, 로컬 RAG, OS 자동화 ## 설계 문서 (구현 시 반드시 참조) @docs/design/00-master-architecture.md @@ -233,6 +276,8 @@ Phase: 9 완료 + 폴리싱 완료 (Phase 1~9 전부 완료) @docs/phases/phase-8.md @docs/phases/phase-9.md @docs/phases/phase-10-killer-features.md +@docs/phases/phase-11-monetization.md +@docs/phases/phase-12-pro-features.md ## RE 노하우 (패턴 적용 근거) @docs/re-findings/speakly-architecture.md diff --git a/docs/phases/phase-11-monetization.md b/docs/phases/phase-11-monetization.md new file mode 100644 index 0000000..db4a402 --- /dev/null +++ b/docs/phases/phase-11-monetization.md @@ -0,0 +1,447 @@ +# Phase 11: 수익화 기반 — LicenseService + Feature Gating + +## 목표 +Freemium 모델 구현. Free/Pro/Pro+ 3단계 티어, 일일 사용량 제한, 라이센스 키 활성화, 업그레이드 유도 UI. +로컬 앱이므로 오프라인 우선 설계 — 키 검증은 최초 1회 온라인, 이후 로컬 검증. + +--- + +## 1. 티어 정의 + +| 기능 | Free | Pro (₩39,000) | Pro+ (₩69,000) | +|------|------|---------------|-----------------| +| 받아쓰기 | 15회/일 | 무제한 | 무제한 | +| LLM 다듬기 | 3회/일 | 무제한 | 무제한 | +| 히스토리 보존 | 3일 | 무제한 | 무제한 | +| 커스텀 명령어 | 프리셋만 | 무제한 생성 | 무제한 생성 | +| 히스토리 내보내기 | X | O | O | +| 실시간 자막 | X | O | O | +| 스크린 컨텍스트 | X | O | O | +| 음성 메모/태그 | X | O | O | +| 음성 단축키 | X | O | O | +| LLM 체인 | X | O | O | +| 파일 전사 | X | X | O | +| 음성 대화 모드 | X | X | O | +| 딕테이션 템플릿 | X | X | O | +| 회의록 자동 요약 | X | X | O | +| 로컬 RAG | X | X | O | +| OS 자동화 | X | X | O | + +--- + +## 2. Feature 열거형 + +```typescript +/** 기능 게이팅 대상 */ +export enum Feature { + // 쿼터 제한 기능 (Free에서 횟수 제한) + DICTATION = 'dictation', + LLM_PROCESS = 'llm_process', + + // Pro 기능 (Free에서 잠금) + HISTORY_UNLIMITED = 'history_unlimited', + HISTORY_EXPORT = 'history_export', + CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create', + LIVE_CAPTION = 'live_caption', + SCREEN_CONTEXT = 'screen_context', + VOICE_MEMO = 'voice_memo', + VOICE_COMMAND = 'voice_command', + LLM_CHAIN = 'llm_chain', + + // Pro+ 기능 (Pro에서도 잠금) + FILE_TRANSCRIPTION = 'file_transcription', + VOICE_CONVERSATION = 'voice_conversation', + DICTATION_TEMPLATE = 'dictation_template', + MEETING_SUMMARY = 'meeting_summary', + LOCAL_RAG = 'local_rag', + OS_AUTOMATION = 'os_automation', +} +``` + +--- + +## 3. 타입 정의 + +```typescript +/** 라이센스 티어 */ +export type LicenseTier = 'free' | 'pro' | 'pro_plus' + +/** 라이센스 정보 (electron-store에 저장) */ +export interface LicenseInfo { + tier: LicenseTier + licenseKey: string | null + activatedAt: number | null + machineId: string + /** 마지막 온라인 검증 시각 */ + lastVerifiedAt: number | null + /** 오프라인 유예 만료 (lastVerifiedAt + 30일) */ + offlineGraceUntil: number | null +} + +/** 일일 사용량 */ +export interface DailyUsage { + date: string // 'YYYY-MM-DD' + dictationCount: number + llmProcessCount: number +} + +/** 쿼터 정보 */ +export interface UsageQuota { + feature: Feature + used: number + limit: number // -1 = 무제한 + remaining: number // -1 = 무제한 + resetAt: string // 다음 리셋 시각 (내일 00:00) ISO 8601 +} + +/** 기능 접근 결과 */ +export interface FeatureAccess { + allowed: boolean + reason?: 'ok' | 'quota_exceeded' | 'tier_required' | 'license_expired' + requiredTier?: LicenseTier + quota?: UsageQuota +} + +/** 업그레이드 유도 이벤트 */ +export interface UpgradePromptEvent { + feature: Feature + reason: 'quota_exceeded' | 'tier_required' + currentTier: LicenseTier + requiredTier: LicenseTier + quota?: UsageQuota +} + +/** 라이센스 활성화 파라미터 */ +export interface ActivateLicenseParams { + licenseKey: string +} + +/** 라이센스 활성화 결과 */ +export interface ActivateLicenseResult { + success: boolean + tier: LicenseTier + message: string +} + +/** 티어별 기능 비교 */ +export interface TierComparison { + feature: string + featureLabel: string + free: boolean | string + pro: boolean | string + proPlus: boolean | string +} +``` + +--- + +## 4. DB 스키마 + +```sql +CREATE TABLE daily_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, -- 'YYYY-MM-DD' + feature TEXT NOT NULL, -- Feature enum value + count INTEGER NOT NULL DEFAULT 0, + UNIQUE(date, feature) +); + +CREATE INDEX idx_daily_usage_date ON daily_usage(date); +``` + +```typescript +// drizzle-orm 스키마 +export const dailyUsage = sqliteTable('daily_usage', { + id: integer('id').primaryKey({ autoIncrement: true }), + date: text('date').notNull(), + feature: text('feature').notNull(), + count: integer('count').notNull().default(0), +}, (table) => [ + uniqueIndex('idx_daily_usage_date_feature').on(table.date, table.feature), + index('idx_daily_usage_date').on(table.date), +]); +``` + +--- + +## 5. IPC 채널 + +```typescript +LICENSE: { + GET_INFO: 'license:getInfo', + ACTIVATE: 'license:activate', + DEACTIVATE: 'license:deactivate', + CHECK_FEATURE: 'license:checkFeature', + GET_USAGE: 'license:getUsage', + GET_ALL_USAGE: 'license:getAllUsage', + GET_TIER_COMPARISON: 'license:getTierComparison', + // Main → Renderer events + UPGRADE_PROMPT: 'license:upgradePrompt', + TIER_CHANGED: 'license:tierChanged', +} +``` + +| 채널 | 방향 | 파라미터 | 반환 | 설명 | +|------|------|---------|------|------| +| `license:getInfo` | handle | void | `LicenseInfo` | 현재 라이센스 정보 | +| `license:activate` | handle | `ActivateLicenseParams` | `ActivateLicenseResult` | 키 활성화 | +| `license:deactivate` | handle | void | void | 키 비활성화 (Free로 복귀) | +| `license:checkFeature` | handle | `{ feature: Feature }` | `FeatureAccess` | 기능 접근 가능 여부 | +| `license:getUsage` | handle | `{ feature: Feature }` | `UsageQuota` | 특정 기능 사용량 | +| `license:getAllUsage` | handle | void | `UsageQuota[]` | 전체 기능 사용량 | +| `license:getTierComparison` | handle | void | `TierComparison[]` | 티어 비교표 | +| `license:upgradePrompt` | send | — | `UpgradePromptEvent` | 업그레이드 유도 이벤트 | +| `license:tierChanged` | send | — | `LicenseInfo` | 티어 변경 알림 | + +--- + +## 6. LicenseService 인터페이스 + +```typescript +interface ILicenseService { + /** 현재 티어 */ + readonly tier: LicenseTier + + /** 라이센스 정보 */ + getInfo(): LicenseInfo + + /** + * 기능 사용 가능 여부 확인. + * 쿼터 기능: 남은 횟수 체크. + * 티어 잠금 기능: 현재 티어로 접근 가능한지. + */ + canUse(feature: Feature): FeatureAccess + + /** + * 기능 사용 소비 (쿼터 차감). + * canUse 통과 후 실제 사용 시 호출. + * 쿼터 초과 시 D3ROError(QuotaExceeded) throw. + */ + consumeQuota(feature: Feature): void + + /** 일일 사용량 조회 */ + getUsage(feature: Feature): UsageQuota + + /** 전체 사용량 조회 */ + getAllUsage(): UsageQuota[] + + /** 라이센스 키 활성화 */ + activate(key: string): Promise + + /** 라이센스 비활성화 */ + deactivate(): void + + /** 티어 비교표 */ + getTierComparison(): TierComparison[] + + /** 업그레이드 유도 이벤트 발생 (내부 + IPC 전파) */ + promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void +} +``` + +### 쿼터 한도 + +```typescript +const QUOTA_LIMITS: Record>> = { + free: { + [Feature.DICTATION]: 15, + [Feature.LLM_PROCESS]: 3, + }, + pro: {}, // 무제한 + pro_plus: {}, // 무제한 +} +``` + +### 티어별 기능 접근 + +```typescript +const FEATURE_TIERS: Record = { + // 모든 티어 (쿼터만 다름) + [Feature.DICTATION]: 'free', + [Feature.LLM_PROCESS]: 'free', + + // Pro 이상 + [Feature.HISTORY_UNLIMITED]: 'pro', + [Feature.HISTORY_EXPORT]: 'pro', + [Feature.CUSTOM_INSTRUCTION_CREATE]: 'pro', + [Feature.LIVE_CAPTION]: 'pro', + [Feature.SCREEN_CONTEXT]: 'pro', + [Feature.VOICE_MEMO]: 'pro', + [Feature.VOICE_COMMAND]: 'pro', + [Feature.LLM_CHAIN]: 'pro', + + // Pro+ 이상 + [Feature.FILE_TRANSCRIPTION]: 'pro_plus', + [Feature.VOICE_CONVERSATION]: 'pro_plus', + [Feature.DICTATION_TEMPLATE]: 'pro_plus', + [Feature.MEETING_SUMMARY]: 'pro_plus', + [Feature.LOCAL_RAG]: 'pro_plus', + [Feature.OS_AUTOMATION]: 'pro_plus', +} +``` + +### 히스토리 보존 정책 + +```typescript +const HISTORY_RETENTION_DAYS: Record = { + free: 3, + pro: -1, // 무제한 + pro_plus: -1, // 무제한 +} +``` + +--- + +## 7. 라이센스 키 검증 + +### 오프라인 우선 설계 + +``` +최초 활성화 (온라인 필수): + 1. 사용자가 키 입력 + 2. LemonSqueezy API 검증: POST /v1/licenses/activate + 3. 응답에서 tier 추출 (meta.tier 또는 variant_id 매핑) + 4. electron-store에 저장: { tier, key, activatedAt, machineId, lastVerifiedAt } + 5. 오프라인 유예: lastVerifiedAt + 30일 + +이후 앱 실행 시: + 1. electron-store에서 라이센스 정보 로드 + 2. tier !== 'free' → 로컬 검증 (machineId 일치, 유예 기간 내) + 3. 30일 경과 → 백그라운드 재검증 시도 + 4. 재검증 실패 → 7일 추가 유예 후 Free로 다운그레이드 + 5. 재검증 성공 → lastVerifiedAt 갱신 +``` + +### machineId 생성 + +```typescript +import { machineIdSync } from 'node-machine-id' +const machineId = machineIdSync(true) // 해시된 하드웨어 ID +``` + +또는 electron-store에 저장된 UUID (하드웨어 변경에 더 관대): + +```typescript +import { randomUUID } from 'crypto' +// 최초 실행 시 1회 생성 후 저장 +const machineId = store.get('machineId') ?? randomUUID() +``` + +--- + +## 8. UI 명세 + +### 8.1 UpgradePromptModal + +쿼터 소진 또는 잠긴 기능 접근 시 표시. + +``` +┌─────────────────────────────────────────────┐ +│ 🔒 오늘의 받아쓰기를 모두 사용했습니다 │ +│ │ +│ Free: 15회/일 → Pro: 무제한 │ +│ │ +│ Pro로 업그레이드하면: │ +│ ✓ 무제한 받아쓰기 │ +│ ✓ AI 텍스트 다듬기 무제한 │ +│ ✓ 실시간 자막 │ +│ ✓ 히스토리 무제한 보존 │ +│ │ +│ [내일 다시 사용하기] [Pro 알아보기 →] │ +└─────────────────────────────────────────────┘ +``` + +### 8.2 UsageIndicator (StatusBar 또는 Dashboard) + +``` +[🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵⚪⚪⚪ 12/15 받아쓰기] +``` + +### 8.3 FeatureGate 래퍼 컴포넌트 + +```tsx +}> + + +``` + +### 8.4 Settings License 탭 + +``` +┌─ License ──────────────────────────────────────┐ +│ │ +│ 현재 플랜: FREE │ +│ │ +│ ┌─ 라이센스 키 ──────────────────────────────┐ │ +│ │ [____________________________] [활성화] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 플랜 비교 ────────────────────────────────┐ │ +│ │ Free Pro Pro+ │ │ +│ │ 받아쓰기 15회/일 무제한 무제한 │ │ +│ │ AI다듬기 3회/일 무제한 무제한 │ │ +│ │ 자막 ✗ ✓ ✓ │ │ +│ │ 파일전사 ✗ ✗ ✓ │ │ +│ │ 대화모드 ✗ ✗ ✓ │ │ +│ │ ... │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ 오늘 사용량: │ +│ 받아쓰기: ████████░░░░░░░ 8/15 │ +│ AI 다듬기: ██░░░░░░░░░░░░ 1/3 │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 9. Feature Gating 통합 포인트 + +| 서비스 | 체크 위치 | Feature | +|--------|----------|---------| +| VoiceModeService.startSession() | 세션 시작 전 | DICTATION | +| LocalLLMService.process() | 처리 시작 전 | LLM_PROCESS | +| CaptionService.start() | 자막 시작 전 | LIVE_CAPTION | +| ScreenContextService.capture() | 캡처 전 | SCREEN_CONTEXT | +| MemoService.addTag() | 태그 추가 전 | VOICE_MEMO | +| VoiceCommandService.match() | 매칭 전 | VOICE_COMMAND | +| ChainService.execute() | 체인 실행 전 | LLM_CHAIN | +| HistoryService.export() | 내보내기 전 | HISTORY_EXPORT | +| CustomInstructionService.create() | 생성 전 | CUSTOM_INSTRUCTION_CREATE | + +--- + +## 10. 에러 코드 + +```typescript +// 850-869: License +LicenseKeyInvalid = 850, +LicenseKeyExpired = 851, +LicenseActivationFailed = 852, +LicenseDeactivationFailed = 853, +LicenseMachineIdMismatch = 854, +LicenseOfflineGraceExpired = 855, +LicenseVerificationFailed = 856, +FeatureNotAvailable = 860, +QuotaExceeded = 861, +TierRequired = 862, +``` + +--- + +## Speakly RE 참조 +- Speakly는 클라우드 인증(AuthService) + 서버 라이센스 모델 사용 +- D3RO는 오프라인 우선 → LemonSqueezy 라이센스 키 + 로컬 검증 +- 쿼터 추적은 Speakly의 RecordStatsService 패턴 차용 + +## 완료 조건 +- [ ] LicenseService 싱글톤 구현 + 초기화 +- [ ] Feature enum + 티어별 접근 매핑 +- [ ] daily_usage 테이블 + 쿼터 추적 +- [ ] 라이센스 키 활성화/비활성화 +- [ ] IPC 핸들러 + preload API +- [ ] UpgradePromptModal UI +- [ ] Settings License 탭 +- [ ] Dashboard 사용량 표시 +- [ ] 기존 서비스에 canUse() 체크 통합 +- [ ] i18n 키 추가 (ko/en) +- [ ] typecheck 통과 diff --git a/docs/phases/phase-11.md b/docs/phases/phase-11.md new file mode 100644 index 0000000..3efd7cb --- /dev/null +++ b/docs/phases/phase-11.md @@ -0,0 +1,38 @@ +# Phase 11: 빌드 & 배포 + 라이선스 시스템 + +## 목표 +프로덕션 빌드 완성 + 라이선스 키 기반 Free/Pro/Pro+ 티어 시스템 구현 + +## 11.1 LicenseService 구현 +- `src/main/services/LicenseService.ts` — 싱글톤 +- electron-store에 LicenseInfo 저장 (machineId 바인딩) +- 기능 게이팅: Feature enum 기반 접근 제어 +- 일일 쿼터: Free 티어 dictation 20회/일, LLM 10회/일 +- LemonSqueezy API 연동: activate/validate/deactivate +- 오프라인 유예: 마지막 검증 후 30일 + +## 11.2 Feature Gate 미들웨어 +- `src/main/utils/feature-gate.ts` — requirePro(), checkAccess() +- 각 서비스 start()/execute()에 게이트 체크 삽입 +- 렌더러: useProFeature() 훅 + +## 11.3 라이선스 UI +- LicenseModal.tsx — 키 입력 + 활성화 + 상태 표시 +- ProUpgradeBanner.tsx — Free 사용자 업그레이드 유도 +- 각 페이지 Pro 기능에 잠금 아이콘 + 업그레이드 넛지 + +## 11.4 AutoUpdaterService +- electron-updater 연동, GitHub Releases 기반 +- 앱 시작 30초 후 체크, 4시간 주기 +- Settings About 탭에 업데이트 버튼 + +## 11.5 빌드 최종화 +- electron-builder.yml 완성 (publish, asarUnpack, NSIS 커스텀) +- GitHub Actions release workflow +- prebuild 스크립트 (SoX + sidecar) + +## 완료 조건 +- npm run typecheck 통과 +- npm run dist로 NSIS 인스톨러 생성 +- Free/Pro 기능 게이팅 동작 +- 라이선스 키 활성화/비활성화 UI 동작 diff --git a/docs/phases/phase-12-pro-features.md b/docs/phases/phase-12-pro-features.md new file mode 100644 index 0000000..997bcbe --- /dev/null +++ b/docs/phases/phase-12-pro-features.md @@ -0,0 +1,56 @@ +# Phase 12-13: Pro/Pro+ 킬러 피처 + +## Phase 12: Pro 피처 강화 (즉시 매출) + +### 12.1 파일 전사 (File Transcription) — Pro+ +- 오디오/비디오 파일 드래그앤드롭 → Whisper 전사 +- ffmpeg(fluent-ffmpeg)로 미디어 → PCM 16kHz 변환 +- 배치 처리: 큰 파일을 30초 청크로 분할 → 순차 전사 +- 진행률 표시 (현재 청크/전체) +- 결과: 전체 텍스트 + 타임스탬프 세그먼트 +- 히스토리에 자동 저장 (mode: 'file-transcription') +- UI: DashboardPage에 드래그 존 또는 별도 페이지 + +### 12.2 회의록 자동 요약 (Meeting Summary) — Pro+ +- CaptionService 세션 종료 시 자동 트리거 +- 전체 전사 텍스트 → Ollama 요약 프롬프트 +- 출력: 3줄 요약 + 핵심 결정사항 + 할 일 목록 +- 마크다운 내보내기 +- 히스토리에 요약 첨부 + +### 12.3 딕테이션 템플릿 (Dictation Templates) — Pro+ +- 템플릿 정의: 이름 + 필드 목록 + 출력 포맷 +- 예: "이메일 템플릿" → 받는 사람, 제목, 본문 순서로 음성 입력 +- 상태 머신: 필드 안내 TTS → 음성 입력 → 다음 필드 → 완료 +- CustomInstruction과 별도 관리 (TemplateService) + +## Phase 13: Pro+ 프리미엄 피처 + +### 13.1 음성 대화 모드 (Voice Conversation) — Pro+ +- THE 킬러 피처: 로컬 ChatGPT Voice Mode +- Whisper STT → Ollama 스트리밍 → TTS 재생 → 다시 듣기 +- 대화 히스토리 컨텍스트 유지 (최근 10턴) +- 문장 단위 TTS 스트리밍 (레이턴시 최적화) +- UI: 전용 대화 윈도우 또는 DashboardPage 내 패널 + +### 13.2 로컬 RAG (Retrieval Augmented Generation) — Pro+ +- Ollama nomic-embed-text 모델로 문서 임베딩 +- better-sqlite3에 임베딩 벡터 저장 (float array → blob) +- 코사인 유사도 검색 (SQLite UDF 또는 JS 계산) +- 지원 포맷: .txt, .md, .pdf (pdf-parse), .docx +- 음성 질문 → 관련 문서 검색 → LLM에 컨텍스트 주입 → 답변 +- UI: Knowledge Base 페이지 (문서 목록 + 인덱싱 상태) + +### 13.3 OS 자동화 (Voice Action) — Pro+ +- 음성 → Ollama가 JSON 액션 플랜 생성 → 실행 +- 액션 타입: 앱 열기, URL 열기, 파일 열기, 키보드 단축키, 텍스트 입력 +- 안전장치: 실행 전 확인 팝업 (위험 액션) +- 사전 정의 명령: "크롬 열어", "메모장 열어", "볼륨 올려" + +--- + +## 구현 우선순위 +1. 파일 전사 (12.1) — 가장 쉬운 Pro+ 피처, ffmpeg만 추가 +2. 회의록 요약 (12.2) — 자막 종료 후 LLM 호출 추가만 +3. 음성 대화 모드 (13.1) — 가장 임팩트 큰 피처 +4. 나머지는 Phase 11 수익 검증 후 결정 diff --git a/docs/phases/phase-12.md b/docs/phases/phase-12.md new file mode 100644 index 0000000..23888fc --- /dev/null +++ b/docs/phases/phase-12.md @@ -0,0 +1,30 @@ +# Phase 12: 랜딩 페이지 & 마케팅 + +## 목표 +Astro 기반 제품 랜딩 페이지 + SEO 블로그 + 출시 마케팅 계획 + +## 12.1 랜딩 페이지 (Astro + Tailwind) +- `landing/` 디렉토리에 별도 프로젝트 +- 섹션: Hero, 데모 비디오, 기능 6개, 경쟁사 비교표, 가격표, 다운로드, FAQ +- LemonSqueezy 결제 위젯 임베드 +- Vercel/Cloudflare Pages 배포 + +## 12.2 프라이버시 정책 +- 로컬 전용 → "We collect nothing" +- 라이선스 키 검증 시 네트워크 통신 명시 +- GDPR/CCPA 대응 + +## 12.3 SEO 블로그 +- "Why local AI voice assistant" +- "D3RO-VOICE vs Krisp vs Otter.ai" +- "How to use Whisper + Ollama for dictation" + +## 12.4 Product Hunt 준비 +- 태그라인, 설명, 갤러리 이미지, 데모 GIF +- 메이커 코멘트 준비 +- PH 유저 50% 할인 프로모 코드 + +## 12.5 소셜 미디어 런칭 +- r/LocalLLaMA, r/selfhosted, HN "Show HN" +- Twitter/X 데모 비디오 스레드 +- dev.to 기술 아티클 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d37446b..bc321a5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -47,6 +47,10 @@ export default defineConfig({ 'popups/command-popup': resolve( __dirname, 'src/renderer/popups/command-popup/index.html' + ), + 'popups/caption-overlay': resolve( + __dirname, + 'src/renderer/popups/caption-overlay/index.html' ) } } diff --git a/package-lock.json b/package-lock.json index 3fb8efd..1cbb6c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@rollup/rollup-win32-x64-msvc": "^4.60.1", "better-sqlite3": "^12.8.0", "drizzle-orm": "^0.45.2", + "electron-audio-loopback": "^1.0.6", "electron-log": "^5.2.0", "electron-store": "^10.0.0", "nanoid": "^5.1.7", @@ -5557,6 +5558,15 @@ "node": ">= 12.20.55" } }, + "node_modules/electron-audio-loopback": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/electron-audio-loopback/-/electron-audio-loopback-1.0.6.tgz", + "integrity": "sha512-QW0ogDqMpWHDAQHmQyssJ+Yh4qR3kWCP3Q4H9WuIXKwVlgkqOYGyt0v/JzbK3tBNTwfqbuHZy86kwCCajxqAdg==", + "license": "MIT", + "peerDependencies": { + "electron": ">=31.0.1" + } + }, "node_modules/electron-builder": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", diff --git a/package.json b/package.json index 5fa0764..9a8eda7 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@rollup/rollup-win32-x64-msvc": "^4.60.1", "better-sqlite3": "^12.8.0", "drizzle-orm": "^0.45.2", + "electron-audio-loopback": "^1.0.6", "electron-log": "^5.2.0", "electron-store": "^10.0.0", "nanoid": "^5.1.7", diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 7f1c4d1..f82d408 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -10,9 +10,11 @@ import { getLocalLLMService } from './services/LocalLLMService' import { getHistoryService } from './services/HistoryService' import { getTextInsertService } from './services/TextInsertService' import { getCustomInstructionService } from './services/CustomInstructionService' +import { getVoiceCommandService } from './services/VoiceCommandService' import { getSoundEffectService } from './services/SoundEffectService' import { getAutoLaunchService } from './services/AutoLaunchService' import { getAudioCaptureService } from './services/AudioCaptureService' +import { initLicenseService } from './services/LicenseService' import { initDatabase } from './db' import { createMainWindow, @@ -43,10 +45,12 @@ export async function bootstrap(): Promise { { name: 'logger', critical: false, fn: initLogger }, { name: 'config', critical: false, fn: initConfig }, { name: 'database', critical: true, fn: initDB }, + { name: 'license', critical: false, fn: initLicense }, { name: 'create-windows', critical: true, fn: createWindows }, { name: 'tray', critical: false, fn: initTray }, { name: 'ipc-handlers', critical: true, fn: initIpcHandlers }, { name: 'custom-instructions', critical: false, fn: initCustomInstructions }, + { name: 'voice-commands', critical: false, fn: initVoiceCommands }, { name: 'sound-effects', critical: false, fn: initSoundEffects }, { name: 'auto-launch', critical: false, fn: initAutoLaunch }, { name: 'popup-preload', critical: false, fn: initPopupWindows }, @@ -85,6 +89,10 @@ async function initDB(): Promise { initDatabase() } +async function initLicense(): Promise { + initLicenseService() +} + async function createWindows(): Promise { createMainWindow() } @@ -107,6 +115,12 @@ async function initCustomInstructions(): Promise { getCustomInstructionService().initialize() } +async function initVoiceCommands(): Promise { + const svc = getVoiceCommandService() + svc.initialize() + svc.initDefaultKeywords() +} + async function initSoundEffects(): Promise { getSoundEffectService().initialize() } @@ -287,10 +301,17 @@ function setupCommandPopupIPC(): void { hideCommandPopup() unregisterPopupNavKeys() - // ConfigService에 활성 명령어 저장 - configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, data.id as never) - configSet('defaultLLMAction', 'custom') - logger.info(`Active command set: ${data.name} (${data.id})`) + if (data.id) { + // 명령어 선택 → 활성 명령어로 설정 + configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, data.id as never) + configSet('defaultLLMAction', 'custom') + logger.info(`Active command set: ${data.name} (${data.id})`) + } else { + // 선택 해제 → 명령어 없음 (원본 삽입) + configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, '' as never) + configSet('defaultLLMAction', 'none') + logger.info('Active command cleared (none)') + } // CMD 페이지 UI 갱신 알림 notifyRenderer('app:dataChanged', { type: 'command-changed', activeId: data.id }) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index d563553..520f20b 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -82,6 +82,27 @@ export function initDatabase(): BetterSQLite3Database { INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated) VALUES (1, 0, 0, 0, 0, ${Date.now()}); + + CREATE TABLE IF NOT EXISTS memo_tags ( + id TEXT PRIMARY KEY, + history_id TEXT NOT NULL, + tag TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_memo_tags_unique ON memo_tags(history_id, tag); + CREATE INDEX IF NOT EXISTS idx_memo_tags_history_id ON memo_tags(history_id); + CREATE INDEX IF NOT EXISTS idx_memo_tags_tag ON memo_tags(tag); + + CREATE TABLE IF NOT EXISTS daily_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + feature TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_usage_date_feature ON daily_usage(date, feature); + CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date); `) db = drizzle(sqlite, { schema }) diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 8e2a1d8..d5ae02a 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -13,7 +13,7 @@ export const history = sqliteTable( focusedApp: text('focused_app'), focusedAppName: text('focused_app_name'), focusedAppWindowTitle: text('focused_app_window_title'), - mode: text('mode', { enum: ['dictation', 'translate', 'command'] }) + mode: text('mode', { enum: ['dictation', 'translate', 'command', 'caption'] }) .notNull() .default('dictation'), status: text('status', { enum: ['completed', 'cancelled', 'error'] }) @@ -75,6 +75,43 @@ export const stats = sqliteTable('stats', { lastUpdated: integer('last_updated').notNull() }) +// ── memo_tags (Phase 10.3) ──────────────────────────────── +export const memoTags = sqliteTable( + 'memo_tags', + { + id: text('id').primaryKey(), + historyId: text('history_id').notNull(), + tag: text('tag').notNull(), + createdAt: integer('created_at').notNull(), + }, + (table) => [ + uniqueIndex('idx_memo_tags_unique').on(table.historyId, table.tag), + index('idx_memo_tags_history_id').on(table.historyId), + index('idx_memo_tags_tag').on(table.tag), + ] +) + +export type MemoTagRow = typeof memoTags.$inferSelect +export type NewMemoTagRow = typeof memoTags.$inferInsert + +// ── daily_usage (Phase 11) ──────────────────────────────── +export const dailyUsage = sqliteTable( + 'daily_usage', + { + id: integer('id').primaryKey({ autoIncrement: true }), + date: text('date').notNull(), + feature: text('feature').notNull(), + count: integer('count').notNull().default(0), + }, + (table) => [ + uniqueIndex('idx_daily_usage_date_feature').on(table.date, table.feature), + index('idx_daily_usage_date').on(table.date), + ] +) + +export type DailyUsageRow = typeof dailyUsage.$inferSelect +export type NewDailyUsageRow = typeof dailyUsage.$inferInsert + // ── 타입 추출 ──────────────────────────────────────────── export type History = typeof history.$inferSelect export type NewHistory = typeof history.$inferInsert diff --git a/src/main/ipc/caption-handlers.ts b/src/main/ipc/caption-handlers.ts new file mode 100644 index 0000000..621481e --- /dev/null +++ b/src/main/ipc/caption-handlers.ts @@ -0,0 +1,69 @@ +// src/main/ipc/caption-handlers.ts +// Phase 10.1: Live Caption IPC 핸들러 + +import { ipcMain, session, desktopCapturer } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors' +import { getCaptionService } from '../services/CaptionService' +import type { CaptionConfig } from '@shared/types' + +export function registerCaptionHandlers(): void { + // 시스템 오디오 루프백: setDisplayMediaRequestHandler로 audio: 'loopback' 설정 + ipcMain.handle('system-audio:enable-loopback', async () => { + session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { + const sources = await desktopCapturer.getSources({ types: ['screen'] }) + if (sources.length === 0) { + callback({}) + return + } + callback({ video: sources[0], audio: 'loopback' }) + }) + return ipcSuccess(undefined) + }) + + ipcMain.handle('system-audio:disable-loopback', async () => { + session.defaultSession.setDisplayMediaRequestHandler(null) + return ipcSuccess(undefined) + }) + ipcMain.handle(IPC_CHANNELS.CAPTION.START, async () => { + try { + await getCaptionService().start() + return ipcSuccess(undefined) + } catch (err) { + const code = + err instanceof Error && 'code' in err + ? (err as { code: number }).code + : ErrorCode.CaptionStartFailed + const message = err instanceof Error ? err.message : String(err) + return ipcError(code, message) + } + }) + + ipcMain.handle(IPC_CHANNELS.CAPTION.STOP, async () => { + try { + await getCaptionService().stop() + return ipcSuccess(undefined) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return ipcError(ErrorCode.CaptionStartFailed, message) + } + }) + + ipcMain.handle(IPC_CHANNELS.CAPTION.GET_STATE, async () => { + return ipcSuccess(getCaptionService().getState()) + }) + + ipcMain.handle(IPC_CHANNELS.CAPTION.SET_CONFIG, async (_event, config: Partial) => { + getCaptionService().setConfig(config) + return ipcSuccess(undefined) + }) + + ipcMain.handle(IPC_CHANNELS.CAPTION.GET_CONFIG, async () => { + return ipcSuccess(getCaptionService().getConfig()) + }) + + // 렌더러 → 메인: 시스템 오디오 PCM 데이터 수신 + ipcMain.on(IPC_CHANNELS.CAPTION.SYSTEM_AUDIO_DATA, (_event, data: ArrayBuffer) => { + getCaptionService().onSystemAudioData(Buffer.from(data)) + }) +} diff --git a/src/main/ipc/chain-handlers.ts b/src/main/ipc/chain-handlers.ts new file mode 100644 index 0000000..8042d73 --- /dev/null +++ b/src/main/ipc/chain-handlers.ts @@ -0,0 +1,88 @@ +// src/main/ipc/chain-handlers.ts +// Phase 10.4: Multi-LLM Chain IPC 핸들러 + +import { ipcMain } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@shared/errors' +import { getChainService } from '../services/ChainService' +import type { + CreateChainParams, + UpdateChainParams, + DeleteChainParams, + ExecuteChainParams +} from '@shared/types' + +export function registerChainHandlers(): void { + ipcMain.handle(IPC_CHANNELS.CHAIN.GET_ALL, async () => { + return ipcSuccess(getChainService().getAll()) + }) + + ipcMain.handle( + IPC_CHANNELS.CHAIN.CREATE, + async (_event, params: CreateChainParams) => { + try { + const result = getChainService().create(params) + return ipcSuccess(result) + } catch (err) { + return ipcError( + ErrorCode.ConfigWriteFailed, + `Failed to create chain: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + ) + + ipcMain.handle( + IPC_CHANNELS.CHAIN.UPDATE, + async (_event, params: UpdateChainParams) => { + try { + const result = getChainService().update(params) + return ipcSuccess(result) + } catch (err) { + if (err instanceof D3ROError && err.code === ErrorCode.ChainNotFound) { + return ipcError(ErrorCode.ChainNotFound, err.message) + } + return ipcError( + ErrorCode.ConfigWriteFailed, + `Failed to update chain: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + ) + + ipcMain.handle( + IPC_CHANNELS.CHAIN.DELETE, + async (_event, params: DeleteChainParams) => { + try { + getChainService().delete(params.id) + return ipcSuccess(undefined) + } catch (err) { + if (err instanceof D3ROError && err.code === ErrorCode.ChainNotFound) { + return ipcError(ErrorCode.ChainNotFound, err.message) + } + return ipcError( + ErrorCode.ConfigWriteFailed, + `Failed to delete chain: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + ) + + ipcMain.handle( + IPC_CHANNELS.CHAIN.EXECUTE, + async (_event, params: ExecuteChainParams) => { + try { + const result = await getChainService().execute(params.chainId, params.text) + return ipcSuccess(result) + } catch (err) { + if (err instanceof D3ROError) { + return ipcError(err.code, err.message) + } + return ipcError( + ErrorCode.ChainExecutionFailed, + `Chain execution failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + ) +} diff --git a/src/main/ipc/config-handlers.ts b/src/main/ipc/config-handlers.ts index 6cacbd5..b2d12be 100644 --- a/src/main/ipc/config-handlers.ts +++ b/src/main/ipc/config-handlers.ts @@ -1,10 +1,18 @@ // src/main/ipc/config-handlers.ts -import { ipcMain } from 'electron' +import { ipcMain, BrowserWindow } 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 { reapplyThemeToAllPopups } from '../windows/WindowManager' + +/** 설정 변경 시 모든 렌더러에 broadcast */ +function broadcastConfigChanged(key: string, value: unknown, previousValue?: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC_CHANNELS.CONFIG.CHANGED, { key, value, previousValue }) + } +} import type { ConfigGetParams, ConfigSetParams, @@ -27,7 +35,9 @@ export function registerConfigHandlers(): void { ipcMain.handle(IPC_CHANNELS.CONFIG.SET, async (_event, params: ConfigSetParams) => { try { + const prev = configGet(params.key) configSet(params.key, params.value as AppConfig[typeof params.key]) + broadcastConfigChanged(params.key, params.value, prev) return ipcSuccess(undefined) } catch { return ipcError(ErrorCode.ConfigWriteFailed, `Failed to write config key: ${params.key}`) @@ -52,7 +62,10 @@ export function registerConfigHandlers(): void { }) ipcMain.handle(IPC_CHANNELS.CONFIG.SET_THEME, async (_event, params: SetThemeParams) => { + const prev = configGet('theme') configSet('theme', params.theme) + broadcastConfigChanged('theme', params.theme, prev) + reapplyThemeToAllPopups() return ipcSuccess(undefined) }) diff --git a/src/main/ipc/context-handlers.ts b/src/main/ipc/context-handlers.ts new file mode 100644 index 0000000..795fb16 --- /dev/null +++ b/src/main/ipc/context-handlers.ts @@ -0,0 +1,47 @@ +// src/main/ipc/context-handlers.ts +// Phase 10.2 스크린 컨텍스트 IPC 핸들러 + +import { ipcMain } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors' +import { getScreenContextService } from '../services/ScreenContextService' + +export function registerContextHandlers(): void { + // context:capture — 활성 윈도우 + 선택 텍스트 캡처 + ipcMain.handle( + IPC_CHANNELS.CONTEXT.CAPTURE, + async (_event, params?: { captureSelectedText?: boolean }) => { + try { + const service = getScreenContextService() + const captureSelectedText = params?.captureSelectedText ?? true + const result = await service.captureContext(captureSelectedText) + return ipcSuccess(result) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return ipcError(ErrorCode.ContextCaptureFailed, message) + } + } + ) + + // context:isEnabled — 스크린 컨텍스트 활성화 여부 + ipcMain.handle(IPC_CHANNELS.CONTEXT.IS_ENABLED, async () => { + return ipcSuccess(getScreenContextService().isEnabled()) + }) + + // context:setEnabled — 스크린 컨텍스트 활성화/비활성화 + ipcMain.handle( + IPC_CHANNELS.CONTEXT.SET_ENABLED, + async (_event, params: { enabled: boolean }) => { + getScreenContextService().setEnabled(params.enabled) + return ipcSuccess(undefined) + } + ) + + // context:getConfig — 현재 설정 조회 (향후 앱별 설정 확장용) + ipcMain.handle(IPC_CHANNELS.CONTEXT.GET_CONFIG, async () => { + const service = getScreenContextService() + return ipcSuccess({ + enabled: service.isEnabled(), + }) + }) +} diff --git a/src/main/ipc/hotkey-handlers.ts b/src/main/ipc/hotkey-handlers.ts index 9216242..b1d5d1f 100644 --- a/src/main/ipc/hotkey-handlers.ts +++ b/src/main/ipc/hotkey-handlers.ts @@ -50,6 +50,20 @@ export function registerHotkeyHandlers(): void { } }) + ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT, async () => { + return ipcSuccess(configGet('captionShortcut')) + }) + + ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, async (_event, params: SetHotkeyParams) => { + try { + configSet('captionShortcut', params.binding) + getHotkeyService().loadFromConfig() + return ipcSuccess(undefined) + } catch { + return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set caption shortcut') + } + }) + ipcMain.handle(IPC_CHANNELS.HOTKEY.IS_ENABLED, async () => { return ipcSuccess(configGet('hotkeyEnabled')) }) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 5745628..768d87d 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -11,6 +11,12 @@ import { registerLLMHandlers } from './llm-handlers' import { registerHistoryHandlers } from './history-handlers' import { registerDictionaryHandlers } from './dictionary-handlers' import { registerInstructionHandlers } from './instruction-handlers' +import { registerMemoHandlers } from './memo-handlers' +import { registerVoiceCommandHandlers } from './voice-command-handlers' +import { registerContextHandlers } from './context-handlers' +import { registerCaptionHandlers } from './caption-handlers' +import { registerChainHandlers } from './chain-handlers' +import { registerLicenseHandlers } from './license-handlers' import { getLogger } from '../services/LoggerService' const logger = getLogger('ipc') @@ -27,5 +33,11 @@ export function registerAllIpcHandlers(): void { registerHistoryHandlers() registerDictionaryHandlers() registerInstructionHandlers() + registerMemoHandlers() + registerVoiceCommandHandlers() + registerContextHandlers() + registerCaptionHandlers() + registerChainHandlers() + registerLicenseHandlers() logger.info('All IPC handlers registered') } diff --git a/src/main/ipc/license-handlers.ts b/src/main/ipc/license-handlers.ts new file mode 100644 index 0000000..6f3b622 --- /dev/null +++ b/src/main/ipc/license-handlers.ts @@ -0,0 +1,100 @@ +// src/main/ipc/license-handlers.ts +// Phase 11: 라이센스 IPC 핸들러 + +import { ipcMain, BrowserWindow } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors' +import { getLicenseService } from '../services/LicenseService' +import type { + ActivateLicenseParams, + UpgradePromptEvent, + LicenseInfo, +} from '@shared/types' +import { Feature } from '@shared/types' + +/** 업그레이드 유도 이벤트를 모든 렌더러에 broadcast */ +function broadcastUpgradePrompt(event: UpgradePromptEvent): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC_CHANNELS.LICENSE.UPGRADE_PROMPT, event) + } +} + +/** 티어 변경을 모든 렌더러에 broadcast */ +function broadcastTierChanged(info: LicenseInfo): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC_CHANNELS.LICENSE.TIER_CHANGED, info) + } +} + +export function registerLicenseHandlers(): void { + const license = getLicenseService() + + // 이벤트 -> 렌더러 전파 + license.on('upgrade-prompt', (event: UpgradePromptEvent) => { + broadcastUpgradePrompt(event) + }) + + license.on('tier-changed', (info: LicenseInfo) => { + broadcastTierChanged(info) + }) + + // ── handle 채널 ── + + ipcMain.handle(IPC_CHANNELS.LICENSE.GET_INFO, async () => { + try { + return ipcSuccess(license.getInfo()) + } catch (err) { + return ipcError(ErrorCode.UnknownError, `Failed to get license info: ${err}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.ACTIVATE, async (_event, params: ActivateLicenseParams) => { + try { + const result = await license.activate(params.licenseKey) + return ipcSuccess(result) + } catch (err) { + return ipcError(ErrorCode.LicenseActivationFailed, `Activation failed: ${err}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.DEACTIVATE, async () => { + try { + await license.deactivate() + return ipcSuccess(undefined) + } catch (err) { + return ipcError(ErrorCode.LicenseDeactivationFailed, `Deactivation failed: ${err}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.CHECK_FEATURE, async (_event, params: { feature: Feature }) => { + try { + return ipcSuccess(license.canUse(params.feature)) + } catch (err) { + return ipcError(ErrorCode.UnknownError, `Feature check failed: ${err}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.GET_USAGE, async (_event, params: { feature: Feature }) => { + try { + return ipcSuccess(license.getUsage(params.feature)) + } catch (err) { + return ipcError(ErrorCode.UnknownError, `Usage query failed: ${err}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.GET_ALL_USAGE, async () => { + try { + return ipcSuccess(license.getAllUsage()) + } catch (err) { + return ipcError(ErrorCode.UnknownError, `Usage query failed: ${err}`) + } + }) + + ipcMain.handle(IPC_CHANNELS.LICENSE.GET_TIER_COMPARISON, async () => { + try { + return ipcSuccess(license.getTierComparison()) + } catch (err) { + return ipcError(ErrorCode.UnknownError, `Tier comparison failed: ${err}`) + } + }) +} diff --git a/src/main/ipc/llm-handlers.ts b/src/main/ipc/llm-handlers.ts index 63883ef..11f73ed 100644 --- a/src/main/ipc/llm-handlers.ts +++ b/src/main/ipc/llm-handlers.ts @@ -5,9 +5,18 @@ import { IPC_CHANNELS } from '@shared/ipc-channels' import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors' import { getLocalLLMService } from '../services/LocalLLMService' import { configGet, configSet } from '../services/ConfigService' +import { getMainWindow } from '../windows/WindowManager' import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@shared/types' export function registerLLMHandlers(): void { + // LLM 가용성 변경 시 렌더러에 상태 전파 + const llm = getLocalLLMService() + llm.on('availability-changed', () => { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send(IPC_CHANNELS.LLM.STATUS_CHANGED, { status: llm.getStatus() }) + } + }) ipcMain.handle(IPC_CHANNELS.LLM.GET_STATUS, async () => { return ipcSuccess(getLocalLLMService().getStatus()) }) diff --git a/src/main/ipc/memo-handlers.ts b/src/main/ipc/memo-handlers.ts new file mode 100644 index 0000000..ddb92e3 --- /dev/null +++ b/src/main/ipc/memo-handlers.ts @@ -0,0 +1,74 @@ +// src/main/ipc/memo-handlers.ts +// Phase 10.3: 음성 메모 태그 IPC 핸들러 + +import { ipcMain } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@shared/errors' +import { getMemoService } from '../services/MemoService' +import type { + GetTagsParams, + AddTagParams, + RemoveTagParams, + SearchByTagParams, + ExportMemoParams +} from '@shared/types' + +export function registerMemoHandlers(): void { + ipcMain.handle(IPC_CHANNELS.MEMO.GET_TAGS, async (_event, params: GetTagsParams) => { + try { + return ipcSuccess(getMemoService().getTagsForEntry(params.historyId)) + } catch { + return ipcError(ErrorCode.DBQueryFailed, 'Failed to get tags for history entry') + } + }) + + ipcMain.handle(IPC_CHANNELS.MEMO.ADD_TAG, async (_event, params: AddTagParams) => { + try { + return ipcSuccess(getMemoService().addTag(params.historyId, params.tag)) + } catch (err) { + if (err instanceof D3ROError && err.code === ErrorCode.MemoTagDuplicate) { + return ipcError(ErrorCode.MemoTagDuplicate, err.message) + } + return ipcError(ErrorCode.DBWriteFailed, 'Failed to add tag') + } + }) + + ipcMain.handle(IPC_CHANNELS.MEMO.REMOVE_TAG, async (_event, params: RemoveTagParams) => { + try { + getMemoService().removeTag(params.historyId, params.tag) + return ipcSuccess(undefined) + } catch (err) { + if (err instanceof D3ROError && err.code === ErrorCode.MemoTagNotFound) { + return ipcError(ErrorCode.MemoTagNotFound, err.message) + } + return ipcError(ErrorCode.DBWriteFailed, 'Failed to remove tag') + } + }) + + ipcMain.handle(IPC_CHANNELS.MEMO.GET_ALL_TAGS, async () => { + try { + return ipcSuccess(getMemoService().getAllTags()) + } catch { + return ipcError(ErrorCode.DBQueryFailed, 'Failed to get all tags') + } + }) + + ipcMain.handle(IPC_CHANNELS.MEMO.SEARCH_BY_TAG, async (_event, params: SearchByTagParams) => { + try { + return ipcSuccess(getMemoService().searchByTag(params)) + } catch { + return ipcError(ErrorCode.DBQueryFailed, 'Failed to search by tag') + } + }) + + ipcMain.handle(IPC_CHANNELS.MEMO.EXPORT, async (_event, params: ExportMemoParams) => { + try { + return ipcSuccess(getMemoService().exportMarkdown(params)) + } catch (err) { + if (err instanceof D3ROError && err.code === ErrorCode.MemoExportFailed) { + return ipcError(ErrorCode.MemoExportFailed, err.message) + } + return ipcError(ErrorCode.MemoExportFailed, 'Failed to export memo') + } + }) +} diff --git a/src/main/ipc/voice-command-handlers.ts b/src/main/ipc/voice-command-handlers.ts new file mode 100644 index 0000000..fa497eb --- /dev/null +++ b/src/main/ipc/voice-command-handlers.ts @@ -0,0 +1,51 @@ +// src/main/ipc/voice-command-handlers.ts +// Phase 10.5: 음성 단축키 IPC 핸들러 + +import { ipcMain } from 'electron' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors' +import { getVoiceCommandService } from '../services/VoiceCommandService' +import type { SetVoiceCommandKeywordsParams, SetVoiceCommandEnabledParams } from '@shared/types' + +export function registerVoiceCommandHandlers(): void { + const CH = IPC_CHANNELS.VOICE_COMMAND + + ipcMain.handle(CH.GET_ALL, async () => { + try { + return ipcSuccess(getVoiceCommandService().getAllRules()) + } catch (error) { + return ipcError( + ErrorCode.VoiceCommandMatchFailed, + `Failed to get voice command rules: ${error instanceof Error ? error.message : String(error)}` + ) + } + }) + + ipcMain.handle(CH.SET_KEYWORDS, async (_event, params: SetVoiceCommandKeywordsParams) => { + try { + getVoiceCommandService().setKeywordsForInstruction(params.instructionId, params.keywords) + return ipcSuccess(undefined) + } catch (error) { + return ipcError( + ErrorCode.VoiceCommandMatchFailed, + `Failed to set keywords: ${error instanceof Error ? error.message : String(error)}` + ) + } + }) + + ipcMain.handle(CH.SET_ENABLED, async (_event, params: SetVoiceCommandEnabledParams) => { + try { + getVoiceCommandService().setEnabled(params.enabled) + return ipcSuccess(undefined) + } catch (error) { + return ipcError( + ErrorCode.VoiceCommandMatchFailed, + `Failed to set enabled state: ${error instanceof Error ? error.message : String(error)}` + ) + } + }) + + ipcMain.handle(CH.IS_ENABLED, async () => { + return ipcSuccess(getVoiceCommandService().isEnabled()) + }) +} diff --git a/src/main/services/AudioCaptureService.ts b/src/main/services/AudioCaptureService.ts index 7340129..729e851 100644 --- a/src/main/services/AudioCaptureService.ts +++ b/src/main/services/AudioCaptureService.ts @@ -37,7 +37,7 @@ interface AudioCaptureEvents { * PCM16 버퍼에서 RMS(Root Mean Square) 오디오 레벨을 계산한다. * 반환값은 0.0 ~ 1.0 범위로 정규화된다. */ -function calculateRMS(buffer: Buffer): number { +export function calculateRMS(buffer: Buffer): number { const samples = buffer.length / AUDIO_FORMAT.BYTES_PER_SAMPLE if (samples === 0) return 0 @@ -325,7 +325,10 @@ class AudioCaptureService extends EventEmitter { const combined = Buffer.concat(this._levelAccumulator) this._levelAccumulator = [] - const level = calculateRMS(combined) + const rawLevel = calculateRMS(combined) + // 로그 스케일: 작은 소리도 크게, 큰 소리는 압축 (DAW 미터 방식) + // pow(x, 0.28) → 0.001→0.04, 0.01→0.14, 0.05→0.35, 0.1→0.52, 0.3→0.80 + const level = rawLevel > 0 ? Math.min(1.0, Math.pow(rawLevel, 0.28)) : 0 this.emit('audio-level', { level, timestamp: Date.now() }) } diff --git a/src/main/services/CaptionService.ts b/src/main/services/CaptionService.ts new file mode 100644 index 0000000..dac9e63 --- /dev/null +++ b/src/main/services/CaptionService.ts @@ -0,0 +1,557 @@ +// src/main/services/CaptionService.ts +// Phase 10.1: Live Caption — 실시간 자막 서비스 +// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴. + +import { EventEmitter } from 'events' +import { nanoid } from 'nanoid' +import { getLogger } from './LoggerService' +import { getAudioCaptureService, calculateRMS } from './AudioCaptureService' +import { getSoundEffectService } from './SoundEffectService' +import { getLocalSTTService } from './LocalSTTService' +import { getHistoryService } from './HistoryService' +import { configGet } from './ConfigService' +import { + showCaptionOverlay, + hideCaptionOverlay, + sendToCaptionOverlay, +} from '../windows/WindowManager' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { + CaptionState, + CaptionSegment, + CaptionConfig, + CaptionSessionSummary, +} from '@shared/types' +import { getMainWindow } from '../windows/WindowManager' + +const logger = getLogger('CaptionService') + +/** 청크 수집 간격 (ms) — 6초로 충분한 컨텍스트 확보 */ +const CHUNK_INTERVAL_MS = 6000 + +/** RMS 무음 임계값 — 이하면 무음으로 판정 (SoX 캡처 레벨이 낮으므로 0.003 사용) */ +const SILENCE_RMS_THRESHOLD = 0.003 + +/** 유성음 프레임 비율 — 이 비율 미만이면 청크 스킵 (환각 방지) */ +const VOICED_FRAME_RATIO = 0.03 + +/** initialPrompt 컨텍스트 윈도우 (자) */ +const CONTEXT_WINDOW_SIZE = 300 + +/** 기본 자막 설정 */ +const DEFAULT_CONFIG: CaptionConfig = { + fontSize: 18, + opacity: 0.85, + maxLines: 3, + autoClearMs: 5000, + audioSource: 'mic', +} + +interface CaptionServiceEvents { + 'state-changed': (state: CaptionState) => void + 'segment': (segment: CaptionSegment) => void + 'delta': (data: { text: string; isFinal: boolean }) => void + 'session-saved': (summary: CaptionSessionSummary) => void + 'error': (error: D3ROError) => void +} + +class CaptionService extends EventEmitter { + private _state: CaptionState = 'inactive' + private _config: CaptionConfig = { ...DEFAULT_CONFIG } + private _sessionId: string | null = null + private _sessionStartedAt: number | null = null + private _segments: CaptionSegment[] = [] + private _audioBuffers: Buffer[] = [] + private _chunkTimer: ReturnType | null = null + private _isProcessingChunk = false + private _previousContext = '' + private _disposed = false + /** 현재 청크 내 유성음 프레임 수 */ + private _voicedFrameCount = 0 + /** 현재 청크 내 총 프레임 수 */ + private _totalFrameCount = 0 + + private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null + /** 시스템 오디오용 별도 버퍼 (audioSource='system' 또는 'both') */ + private _systemAudioBuffers: Buffer[] = [] + private _systemVoicedFrameCount = 0 + private _systemTotalFrameCount = 0 + + // ── 공개 접근자 ── + + getState(): CaptionState { + return this._state + } + + getConfig(): CaptionConfig { + return { ...this._config } + } + + setConfig(partial: Partial): void { + this._config = { ...this._config, ...partial } + logger.debug(`Caption config updated: ${JSON.stringify(this._config)}`) + + // 오버레이에 설정 변경 알림 + sendToCaptionOverlay('caption:config', this._config) + } + + // ── 시작 ── + + async start(): Promise { + if (this._disposed) { + throw new D3ROError( + ErrorCode.CaptionStartFailed, + 'CaptionService가 이미 dispose되었습니다', + ) + } + + if (this._state !== 'inactive') { + throw new D3ROError( + ErrorCode.CaptionAlreadyActive, + `캡션이 이미 활성 상태입니다: ${this._state}`, + ) + } + + this._setState('starting') + + // 오버레이를 즉시 표시 (로딩 상태) + showCaptionOverlay() + sendToCaptionOverlay('caption:config', this._config) + sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' }) + + try { + // STT 초기화 (모델 로딩 — 시간 소요) + const sttService = getLocalSTTService() + const modelId = configGet('sttModelId') as string | undefined + await sttService.initialize(modelId) + + // 세션 초기화 + this._sessionId = nanoid() + this._sessionStartedAt = Date.now() + this._segments = [] + this._audioBuffers = [] + // 초기 컨텍스트 힌트 — Whisper 첫 청크 환각 방지 + const lang = configGet('sttLanguage') as string | undefined + this._previousContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : '' + this._isProcessingChunk = false + this._voicedFrameCount = 0 + this._totalFrameCount = 0 + + // ConfigService에서 저장된 오디오 소스 읽기 + const savedSource = configGet('captionAudioSource' as keyof import('@shared/types').AppConfig) as unknown as string + if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) { + this._config.audioSource = savedSource as 'mic' | 'system' | 'both' + } + const { audioSource } = this._config + logger.info(`Caption audioSource: ${audioSource}`) + + // 마이크 캡처 (mic 또는 both) + if (audioSource === 'mic' || audioSource === 'both') { + const audioCaptureService = getAudioCaptureService() + this._audioDataHandler = (payload) => { + this._onAudioData(payload.buffer) + } + audioCaptureService.on('audio-data', this._audioDataHandler) + await audioCaptureService.start() + } + + // 시스템 오디오 캡처 요청 (system 또는 both) — 렌더러에 시작 요청 + if (audioSource === 'system' || audioSource === 'both') { + this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {}) + this._systemAudioBuffers = [] + this._systemVoicedFrameCount = 0 + this._systemTotalFrameCount = 0 + } + + // 청크 타이머 시작 + this._chunkTimer = setInterval(() => { + const promises: Promise[] = [] + + // 마이크 청크 처리 + if (audioSource === 'mic' || audioSource === 'both') { + promises.push( + this._processChunk().catch((err: unknown) => { + logger.error(`마이크 청크 처리 실패: ${err instanceof Error ? err.message : String(err)}`) + }) + ) + } + + // 시스템 오디오 청크 처리 + if (audioSource === 'system' || audioSource === 'both') { + promises.push( + this._processSystemChunk().catch((err: unknown) => { + logger.error(`시스템 오디오 청크 처리 실패: ${err instanceof Error ? err.message : String(err)}`) + }) + ) + } + + Promise.all(promises).catch(() => { /* 개별 에러는 이미 로깅됨 */ }) + }, CHUNK_INTERVAL_MS) + + this._setState('active') + getSoundEffectService().play('recording-start') + logger.info(`Live Caption 시작: sessionId=${this._sessionId}`) + } catch (err) { + this._setState('inactive') + const d3roErr = + err instanceof D3ROError + ? err + : new D3ROError( + ErrorCode.CaptionStartFailed, + `캡션 시작 실패: ${err instanceof Error ? err.message : String(err)}`, + ) + this.emit('error', d3roErr) + throw d3roErr + } + } + + // ── 중지 ── + + async stop(): Promise { + if (this._state !== 'active' && this._state !== 'starting') { + logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`) + return + } + + this._setState('stopping') + + // 타이머 정리 + if (this._chunkTimer) { + clearInterval(this._chunkTimer) + this._chunkTimer = null + } + + // 잔여 오디오 처리 + try { + await this._processChunk() + } catch (err) { + logger.warn( + `잔여 오디오 처리 실패: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + // 마이크 캡처 정리 + if (this._audioDataHandler) { + const audioCaptureService = getAudioCaptureService() + audioCaptureService.off('audio-data', this._audioDataHandler) + this._audioDataHandler = null + await audioCaptureService.stop() + } + + // 시스템 오디오 캡처 중지 요청 + this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {}) + this._systemAudioBuffers = [] + + // 오버레이 숨김 + hideCaptionOverlay() + + // 세션을 히스토리에 저장 + const summary = this._saveSession() + + this._sessionId = null + this._sessionStartedAt = null + this._audioBuffers = [] + this._previousContext = '' + + this._setState('inactive') + getSoundEffectService().play('recording-stop') + logger.info('Live Caption 중지') + + if (summary) { + this.emit('session-saved', summary) + this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary) + } + } + + // ── dispose ── + + async dispose(): Promise { + if (this._disposed) return + this._disposed = true + + if (this._state === 'active' || this._state === 'starting') { + await this.stop() + } + + this.removeAllListeners() + logger.info('CaptionService disposed') + } + + // ── 내부: 오디오 데이터 수집 ── + + private _onAudioData(buffer: Buffer): void { + if (this._state !== 'active') return + this._audioBuffers.push(buffer) + + // RMS 기반 유성음 감지 (환각 방지) + this._totalFrameCount++ + const rms = calculateRMS(buffer) + if (rms >= SILENCE_RMS_THRESHOLD) { + this._voicedFrameCount++ + } + } + + /** + * 시스템 오디오 PCM16 데이터를 수신한다 (렌더러 → IPC → 여기). + * 렌더러의 systemAudioCapture.ts가 이미 16kHz mono PCM16으로 변환하여 전송. + */ + onSystemAudioData(buffer: Buffer): void { + if (this._state !== 'active') return + this._systemAudioBuffers.push(buffer) + + this._systemTotalFrameCount++ + const rms = calculateRMS(buffer) + if (rms >= SILENCE_RMS_THRESHOLD) { + this._systemVoicedFrameCount++ + } + } + + // ── 내부: 3초 청크 처리 ── + + private async _processChunk(): Promise { + if (this._audioBuffers.length === 0) return + if (this._isProcessingChunk) return + + this._isProcessingChunk = true + + // VAD 카운터 리셋 (로깅용) + const voicedRatio = this._totalFrameCount > 0 + ? this._voicedFrameCount / this._totalFrameCount + : 0 + this._voicedFrameCount = 0 + this._totalFrameCount = 0 + logger.debug(`청크 처리: 유성음 비율 ${(voicedRatio * 100).toFixed(1)}%`) + + // VAD 필터링은 faster-whisper 사이드카에서 처리 (vadFilter: true) + // SoX의 마이크 캡처 레벨이 매우 낮아 로컬 RMS 게이트는 신뢰 불가 + + try { + const merged = Buffer.concat(this._audioBuffers) + this._audioBuffers = [] + + // 최소 오디오 크기 확인 (100ms 분량 이상) + const minBytes = 16000 * 2 * 0.1 // 100ms @ 16kHz 16bit mono + if (merged.length < minBytes) { + return + } + + const sttService = getLocalSTTService() + const language = configGet('sttLanguage') as string | undefined + + const result = await sttService.transcribe(merged, { + language: language ?? 'auto', + initialPrompt: this._previousContext, + vadFilter: true, + }) + + if (!result.text || result.text.trim().length === 0) { + return + } + + const segment: CaptionSegment = { + id: nanoid(), + text: result.text.trim(), + timestamp: Date.now(), + isFinal: true, + } + + this._segments.push(segment) + + // 다음 청크를 위한 컨텍스트 업데이트 (연속성 유지) + this._previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE) + + // 이벤트 emit + this.emit('segment', segment) + + // 오버레이 윈도우에 전송 + sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment) + + // 메인 윈도우에 전송 (Dashboard 등에서 사용) + this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment) + + logger.debug(`자막 세그먼트: "${segment.text.substring(0, 50)}"`) + } catch (err) { + const d3roErr = + err instanceof D3ROError + ? err + : new D3ROError( + ErrorCode.CaptionSTTFailed, + `자막 전사 실패: ${err instanceof Error ? err.message : String(err)}`, + ) + logger.error(`자막 전사 에러: ${d3roErr.message}`) + this.emit('error', d3roErr) + } finally { + this._isProcessingChunk = false + } + } + + // ── 내부: 시스템 오디오 청크 처리 ── + + private async _processSystemChunk(): Promise { + if (this._systemAudioBuffers.length === 0) return + + // VAD 게이트 + const voicedRatio = this._systemTotalFrameCount > 0 + ? this._systemVoicedFrameCount / this._systemTotalFrameCount + : 0 + this._systemVoicedFrameCount = 0 + this._systemTotalFrameCount = 0 + + if (voicedRatio < VOICED_FRAME_RATIO) { + this._systemAudioBuffers = [] + logger.debug(`시스템 오디오 청크 스킵: 유성음 비율 ${(voicedRatio * 100).toFixed(1)}%`) + return + } + + const merged = Buffer.concat(this._systemAudioBuffers) + this._systemAudioBuffers = [] + + const minBytes = 16000 * 2 * 0.1 + if (merged.length < minBytes) return + + try { + const sttService = getLocalSTTService() + const language = configGet('sttLanguage') as string | undefined + + const result = await sttService.transcribe(merged, { + language: language ?? 'auto', + initialPrompt: this._previousContext, + vadFilter: true, + }) + + if (!result.text || result.text.trim().length === 0) return + + const segment: CaptionSegment = { + id: nanoid(), + text: result.text.trim(), + timestamp: Date.now(), + isFinal: true, + } + + this._segments.push(segment) + this._previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE) + + this.emit('segment', segment) + sendToCaptionOverlay(IPC_CHANNELS.CAPTION.SEGMENT, segment) + this._sendToMainWindow(IPC_CHANNELS.CAPTION.SEGMENT, segment) + + logger.debug(`시스템 자막: "${segment.text.substring(0, 50)}"`) + } catch (err) { + logger.error(`시스템 오디오 전사 실패: ${err instanceof Error ? err.message : String(err)}`) + } + } + + // ── 내부: 세션 저장 ── + + private _saveSession(): CaptionSessionSummary | null { + if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) { + return null + } + + const endedAt = Date.now() + const totalDurationMs = endedAt - this._sessionStartedAt + const fullText = this._segments.map((s) => s.text).join(' ') + const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length + + // 히스토리에 저장 (mode: 'caption') + try { + const historyService = getHistoryService() + historyService.create({ + originalText: fullText, + polishedText: null, + focusedApp: null, + focusedAppName: null, + focusedAppWindowTitle: null, + mode: 'caption', + status: 'completed', + errorCode: null, + audioLocalPath: null, + duration: totalDurationMs / 1000, + detectedLanguage: null, + micDevice: null, + wordCount, + sttModel: configGet('sttModelId') as string | null, + llmModel: null, + sttLatencyMs: null, + llmLatencyMs: null, + appVersion: '1.0.0', + }) + logger.info( + `캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`, + ) + } catch (err) { + logger.error( + `캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const summary: CaptionSessionSummary = { + sessionId: this._sessionId, + segments: [...this._segments], + startedAt: this._sessionStartedAt, + endedAt, + totalDurationMs, + } + + this._segments = [] + return summary + } + + // ── 내부: 상태 전이 ── + + private _setState(newState: CaptionState): void { + if (this._state === newState) return + const prev = this._state + this._state = newState + logger.debug(`CaptionState: ${prev} -> ${newState}`) + this.emit('state-changed', newState) + this._sendToMainWindow(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: newState }) + sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: newState }) + } + + // ── 내부: 메인 윈도우에 IPC 전송 ── + + private _sendToMainWindow(channel: string, data: unknown): void { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send(channel, data) + } + } + + // ── 타입 안전한 이벤트 메서드 오버라이드 ── + + override emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } + + override on( + event: K, + listener: CaptionServiceEvents[K], + ): this { + return super.on(event, listener) + } + + override off( + event: K, + listener: CaptionServiceEvents[K], + ): this { + return super.off(event, listener) + } +} + +// ── 싱글톤 ── + +let instance: CaptionService | null = null + +export function getCaptionService(): CaptionService { + if (!instance) { + instance = new CaptionService() + } + return instance +} + +export { CaptionService } diff --git a/src/main/services/ChainService.ts b/src/main/services/ChainService.ts new file mode 100644 index 0000000..ba1532c --- /dev/null +++ b/src/main/services/ChainService.ts @@ -0,0 +1,259 @@ +// src/main/services/ChainService.ts +// Phase 10.4: Multi-LLM Chain 서비스. +// LLMChain을 electron-store에 저장하고, 체인을 순차 실행한다. + +import { nanoid } from 'nanoid' +import { getLogger } from './LoggerService' +import { configGet } from './ConfigService' +import { getCustomInstructionService } from './CustomInstructionService' +import { getLocalLLMService } from './LocalLLMService' +import { getMainWindow } from '../windows/WindowManager' +import { IPC_CHANNELS } from '@shared/ipc-channels' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { + LLMChain, + ChainStep, + CreateChainParams, + UpdateChainParams, + ChainProgress, + ChainExecutionResult +} from '@shared/types' + +const logger = getLogger('chain-service') + +// ============================================================ +// 저장소 (electron-store, CustomInstructionService 패턴) +// ============================================================ + +let chains: LLMChain[] = [] +let initialized = false + +function loadChains(): LLMChain[] { + try { + const stored = configGet('llmChains' as never) as LLMChain[] | undefined + if (Array.isArray(stored) && stored.length > 0) { + return stored + } + } catch { + // 첫 실행 시 키가 없을 수 있음 + } + return [] +} + +function saveChains(): void { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { configSet } = require('./ConfigService') as { + configSet: (key: never, value: never) => void + } + configSet('llmChains' as never, chains as never) + } catch (error) { + logger.warn( + `Failed to save chains: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +/** 메인 윈도우 렌더러에 IPC 이벤트 전송 */ +function sendProgressToRenderer(progress: ChainProgress): void { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send(IPC_CHANNELS.CHAIN.PROGRESS, progress) + } +} + +// ============================================================ +// ChainService +// ============================================================ + +class ChainService { + private _cancelRequested = false + + initialize(): void { + if (initialized) return + chains = loadChains() + initialized = true + logger.info(`ChainService initialized (${chains.length} chains)`) + } + + getAll(): LLMChain[] { + return [...chains] + } + + getById(id: string): LLMChain | null { + return chains.find((c) => c.id === id) ?? null + } + + create(params: CreateChainParams): LLMChain { + const now = Date.now() + const chain: LLMChain = { + id: nanoid(), + name: params.name, + steps: params.steps, + createdAt: now, + updatedAt: now + } + + chains.push(chain) + saveChains() + logger.info(`Chain created: "${chain.name}" (${chain.steps.length} steps)`) + return chain + } + + update(params: UpdateChainParams): LLMChain { + const index = chains.findIndex((c) => c.id === params.id) + if (index === -1) { + throw new D3ROError(ErrorCode.ChainNotFound, `Chain not found: ${params.id}`) + } + + const existing = chains[index] + const updated: LLMChain = { + ...existing, + name: params.name ?? existing.name, + steps: params.steps ?? existing.steps, + updatedAt: Date.now() + } + + chains[index] = updated + saveChains() + logger.info(`Chain updated: "${updated.name}"`) + return updated + } + + delete(id: string): void { + const index = chains.findIndex((c) => c.id === id) + if (index === -1) { + throw new D3ROError(ErrorCode.ChainNotFound, `Chain not found: ${id}`) + } + + const removed = chains.splice(index, 1)[0] + saveChains() + logger.info(`Chain deleted: "${removed.name}"`) + } + + /** + * 체인을 순차 실행한다. + * 각 단계마다 CustomInstruction의 프롬프트로 LLM을 호출하고, + * 이전 단계 결과를 다음 단계의 입력으로 전달한다. + */ + async execute(chainId: string, inputText: string): Promise { + const chain = this.getById(chainId) + if (!chain) { + throw new D3ROError(ErrorCode.ChainNotFound, `Chain not found: ${chainId}`) + } + + if (chain.steps.length === 0) { + throw new D3ROError(ErrorCode.ChainExecutionFailed, 'Chain has no steps') + } + + this._cancelRequested = false + const startTime = Date.now() + const stepResults: Array<{ instructionId: string; output: string; durationMs: number }> = [] + let previousOutput = inputText + + const llm = getLocalLLMService() + const instructionService = getCustomInstructionService() + + logger.info( + `Executing chain "${chain.name}" (${chain.steps.length} steps) with input length ${inputText.length}` + ) + + for (let i = 0; i < chain.steps.length; i++) { + // 취소 확인 + if (this._cancelRequested) { + logger.info(`Chain execution cancelled at step ${i + 1}/${chain.steps.length}`) + throw new D3ROError(ErrorCode.ChainCancelled, 'Chain execution cancelled') + } + + const step: ChainStep = chain.steps[i] + const instruction = instructionService.getById(step.instructionId) + + if (!instruction) { + throw new D3ROError( + ErrorCode.ChainStepFailed, + `Instruction not found for step ${i + 1}: ${step.instructionId}` + ) + } + + // 입력 소스 결정 + const stepInput = step.inputSource === 'original' ? inputText : previousOutput + + // 진행 상황 전송 + const progress: ChainProgress = { + chainId, + currentStep: i + 1, + totalSteps: chain.steps.length, + stepName: instruction.name, + intermediateText: previousOutput + } + sendProgressToRenderer(progress) + + // LLM 호출 + const stepStart = Date.now() + try { + const result = await llm.processText(stepInput, 'custom', undefined, instruction.prompt) + const stepDuration = Date.now() - stepStart + + stepResults.push({ + instructionId: step.instructionId, + output: result, + durationMs: stepDuration + }) + previousOutput = result + + logger.info( + `Chain step ${i + 1}/${chain.steps.length} ("${instruction.name}") completed in ${stepDuration}ms` + ) + } catch (error) { + if (error instanceof D3ROError && error.code === ErrorCode.ChainCancelled) { + throw error + } + const msg = + error instanceof Error ? error.message : String(error) + throw new D3ROError( + ErrorCode.ChainStepFailed, + `Step ${i + 1} ("${instruction.name}") failed: ${msg}` + ) + } + } + + const totalDuration = Date.now() - startTime + logger.info(`Chain "${chain.name}" completed in ${totalDuration}ms`) + + return { + chainId, + finalText: previousOutput, + steps: stepResults, + totalDurationMs: totalDuration + } + } + + /** + * 진행 중인 체인 실행을 취소한다. + * 현재 단계가 완료된 후 다음 단계 시작 전에 중단된다. + */ + cancelExecution(): void { + this._cancelRequested = true + // LLM 생성도 취소 + getLocalLLMService().cancelGeneration() + logger.info('Chain execution cancel requested') + } + + dispose(): void { + this._cancelRequested = true + logger.info('ChainService disposed') + } +} + +// ============================================================ +// 싱글톤 +// ============================================================ + +let instance: ChainService | null = null + +export function getChainService(): ChainService { + if (!instance) { + instance = new ChainService() + } + return instance +} diff --git a/src/main/services/ConfigService.ts b/src/main/services/ConfigService.ts index 63a1df1..6c9e481 100644 --- a/src/main/services/ConfigService.ts +++ b/src/main/services/ConfigService.ts @@ -52,6 +52,14 @@ const CONFIG_DEFAULTS: AppConfig = { meta: false, displayLabel: 'Ctrl + Right Alt' }, + captionShortcut: { + keyCode: 0xa5, + ctrl: true, + alt: false, + shift: true, + meta: false, + displayLabel: 'Ctrl + Shift + Right Alt' + }, hotkeyEnabled: true, insertMethod: 'clipboard', autoInsert: true, @@ -59,6 +67,7 @@ const CONFIG_DEFAULTS: AppConfig = { dictationEnabled: true, agentModeEnabled: false, handsFreeEnabled: false, + screenContextEnabled: false, } let store: ElectronStore | null = null diff --git a/src/main/services/HotkeyService.ts b/src/main/services/HotkeyService.ts index 60bdee8..40a7232 100644 --- a/src/main/services/HotkeyService.ts +++ b/src/main/services/HotkeyService.ts @@ -307,6 +307,7 @@ class HotkeyService extends EventEmitter { const dictationBinding = configGet('dictationShortcut') const handsFreeBinding = configGet('handsFreeShortcut') const commandBinding = configGet('commandShortcut') + const captionBinding = configGet('captionShortcut') // 기존 핫키 초기화 this._registeredHotkeys.clear() @@ -326,6 +327,11 @@ class HotkeyService extends EventEmitter { bindingToConfig('voice-command', commandBinding, false, false) ) + // Caption: toggle (자막 모드 시작/정지), 더블프레스 비활성 + this.registerHotkey( + bindingToConfig('voice-caption', captionBinding, false, false) + ) + logger.info( `Loaded ${this._registeredHotkeys.size} hotkeys from config` ) diff --git a/src/main/services/LicenseService.ts b/src/main/services/LicenseService.ts new file mode 100644 index 0000000..3039a1d --- /dev/null +++ b/src/main/services/LicenseService.ts @@ -0,0 +1,822 @@ +// src/main/services/LicenseService.ts +// Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 + LemonSqueezy 연동 + +import { EventEmitter } from 'events' +import { createHash } from 'crypto' +import os from 'os' +import { eq, and } from 'drizzle-orm' +import { getLogger } from './LoggerService' +import { getDatabase } from '../db' +import { dailyUsage } from '../db/schema' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { + LicenseTier, + LicenseInfo, + UsageQuota, + FeatureAccess, + UpgradePromptEvent, + ActivateLicenseResult, + TierComparison, +} from '@shared/types' +import { Feature } from '@shared/types' + +const logger = getLogger('license') + +// ── 머신 ID 생성 ───────────────────────────────────────── +function generateMachineId(): string { + const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}` + return createHash('sha256').update(raw).digest('hex').substring(0, 32) +} + +// ── 티어별 쿼터 한도 ────────────────────────────────────── +// -1 = 무제한, 값이 있으면 일일 한도 +const QUOTA_LIMITS: Record>> = { + free: { + [Feature.DICTATION]: 20, + [Feature.LLM_PROCESS]: 10, + }, + pro: {}, + pro_plus: {}, +} + +// ── 기능별 최소 필요 티어 ────────────────────────────────── +const FEATURE_MIN_TIER: Record = { + [Feature.DICTATION]: 'free', + [Feature.LLM_PROCESS]: 'free', + + [Feature.HISTORY_UNLIMITED]: 'pro', + [Feature.HISTORY_EXPORT]: 'pro', + [Feature.CUSTOM_INSTRUCTION_CREATE]: 'pro', + [Feature.LIVE_CAPTION]: 'pro', + [Feature.SCREEN_CONTEXT]: 'pro', + [Feature.VOICE_MEMO]: 'pro', + [Feature.VOICE_COMMAND]: 'pro', + [Feature.LLM_CHAIN]: 'pro', + + [Feature.FILE_TRANSCRIPTION]: 'pro_plus', + [Feature.VOICE_CONVERSATION]: 'pro_plus', + [Feature.DICTATION_TEMPLATE]: 'pro_plus', + [Feature.MEETING_SUMMARY]: 'pro_plus', + [Feature.LOCAL_RAG]: 'pro_plus', + [Feature.OS_AUTOMATION]: 'pro_plus', +} + +// ── 히스토리 보존 기간 (일) ──────────────────────────────── +export const HISTORY_RETENTION_DAYS: Record = { + free: 3, + pro: -1, + pro_plus: -1, +} + +// ── 티어 순서 (비교용) ──────────────────────────────────── +const TIER_ORDER: Record = { + free: 0, + pro: 1, + pro_plus: 2, +} + +/** 오프라인 유예 기간: 30일 */ +const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000 + +/** 온라인 재검증 주기: 30일 */ +const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000 + +/** LemonSqueezy API base URL */ +const LEMONSQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses' + +function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean { + return TIER_ORDER[current] >= TIER_ORDER[required] +} + +function getTodayDate(): string { + const now = new Date() + const y = now.getFullYear() + const m = String(now.getMonth() + 1).padStart(2, '0') + const d = String(now.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +function getTomorrowMidnight(): string { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + tomorrow.setHours(0, 0, 0, 0) + return tomorrow.toISOString() +} + +/** LemonSqueezy activate 응답 타입 */ +interface LemonSqueezyActivateResponse { + activated: boolean + error?: string + license_key?: { + id: number + status: string + key: string + activation_limit: number + activation_usage: number + } + instance?: { + id: string + name: string + } + meta?: { + store_id: number + order_id: number + product_id: number + product_name: string + variant_id: number + variant_name: string + } +} + +/** LemonSqueezy validate 응답 타입 */ +interface LemonSqueezyValidateResponse { + valid: boolean + error?: string + license_key?: { + id: number + status: string + key: string + activation_limit: number + activation_usage: number + } + meta?: { + store_id: number + order_id: number + product_id: number + product_name: string + variant_id: number + variant_name: string + } +} + +/** variant_name → LicenseTier 매핑 */ +function variantNameToTier(variantName: string): LicenseTier { + const lower = variantName.toLowerCase() + if (lower.includes('pro_plus') || lower.includes('pro+') || lower.includes('proplus')) { + return 'pro_plus' + } + if (lower.includes('pro')) { + return 'pro' + } + return 'free' +} + +// ── LicenseService 싱글톤 ────────────────────────────────── +class LicenseService extends EventEmitter { + private _info: LicenseInfo + private _initialized = false + /** LemonSqueezy instance_id (디바이스별, 비활성화에 필요) */ + private _instanceId: string | null = null + + constructor() { + super() + this._info = { + tier: 'free', + licenseKey: null, + activatedAt: null, + machineId: '', + lastVerifiedAt: null, + offlineGraceUntil: null, + } + } + + /** 서비스 초기화 — bootstrap에서 호출 */ + initialize(): void { + // machineId: 하드웨어 기반 해시 생성 + const storedMachineId = this._readStoredField('licenseMachineId') + const generatedId = generateMachineId() + + if (storedMachineId && storedMachineId === generatedId) { + this._info.machineId = storedMachineId + } else if (storedMachineId) { + // 하드웨어가 바뀐 경우 — 기존 ID 유지 (이미 활성화된 키와 연결) + this._info.machineId = storedMachineId + logger.warn('Hardware changed but keeping existing machineId for license continuity') + } else { + this._info.machineId = generatedId + this._writeStoredField('licenseMachineId', this._info.machineId) + } + + // 저장된 라이센스 정보 로드 + const storedTier = this._readStoredField('licenseTier') + const storedKey = this._readStoredField('licenseKey') + const storedActivatedAt = this._readStoredField('licenseActivatedAt') + const storedLastVerified = this._readStoredField('licenseLastVerifiedAt') + const storedGrace = this._readStoredField('licenseOfflineGraceUntil') + this._instanceId = this._readStoredField('licenseInstanceId') + + if (storedTier && storedTier !== 'free' && storedKey) { + this._info.tier = storedTier + this._info.licenseKey = storedKey + this._info.activatedAt = storedActivatedAt ?? null + this._info.lastVerifiedAt = storedLastVerified ?? null + this._info.offlineGraceUntil = storedGrace ?? null + + // 오프라인 유예 기간 체크 + if (this._info.offlineGraceUntil && Date.now() > this._info.offlineGraceUntil) { + logger.warn('Offline grace period expired, downgrading to free') + this._downgradeToFree() + } else { + // 온라인 재검증이 필요한 경우 비동기로 시도 + this._tryPeriodicValidation() + } + } + + this._initialized = true + logger.info(`LicenseService initialized: tier=${this._info.tier}, machineId=${this._info.machineId.substring(0, 8)}...`) + } + + // ── Public API ────────────────────────────────────────── + + get tier(): LicenseTier { + return this._info.tier + } + + getInfo(): LicenseInfo { + return { ...this._info } + } + + /** + * 기능 사용 가능 여부 확인. + * 티어 잠금 -> FeatureAccess { allowed: false, reason: 'tier_required' } + * 쿼터 초과 -> FeatureAccess { allowed: false, reason: 'quota_exceeded' } + */ + canUse(feature: Feature): FeatureAccess { + const minTier = FEATURE_MIN_TIER[feature] + + // 티어 체크 + if (!tierAtLeast(this._info.tier, minTier)) { + return { + allowed: false, + reason: 'tier_required', + requiredTier: minTier, + } + } + + // 쿼터 체크 (쿼터가 있는 기능만) + const tierLimits = QUOTA_LIMITS[this._info.tier] + const limit = tierLimits[feature] + if (limit !== undefined) { + const quota = this.getUsage(feature) + if (quota.remaining === 0) { + return { + allowed: false, + reason: 'quota_exceeded', + requiredTier: 'pro', + quota, + } + } + } + + return { allowed: true, reason: 'ok' } + } + + /** + * 기능 사용 소비 (쿼터 차감). + * canUse 통과 후 실제 사용 시 호출. + */ + consumeQuota(feature: Feature): void { + const access = this.canUse(feature) + if (!access.allowed) { + if (access.reason === 'quota_exceeded') { + this.promptUpgrade(feature, 'quota_exceeded') + throw new D3ROError( + ErrorCode.QuotaExceeded, + `Daily quota exceeded for ${feature}`, + { feature, quota: access.quota } + ) + } + if (access.reason === 'tier_required') { + this.promptUpgrade(feature, 'tier_required') + throw new D3ROError( + ErrorCode.TierRequired, + `Feature ${feature} requires ${access.requiredTier} tier`, + { feature, requiredTier: access.requiredTier } + ) + } + } + + // 쿼터 있는 기능만 DB에 기록 + const tierLimits = QUOTA_LIMITS[this._info.tier] + if (tierLimits[feature] !== undefined) { + this._incrementUsage(feature) + } + } + + /** 일일 사용량 조회 */ + getUsage(feature: Feature): UsageQuota { + const tierLimits = QUOTA_LIMITS[this._info.tier] + const limit = tierLimits[feature] + + // 쿼터 없는 기능 (무제한) + if (limit === undefined) { + return { + feature, + used: 0, + limit: -1, + remaining: -1, + resetAt: getTomorrowMidnight(), + } + } + + const today = getTodayDate() + const used = this._getUsageCount(today, feature) + + return { + feature, + used, + limit, + remaining: Math.max(0, limit - used), + resetAt: getTomorrowMidnight(), + } + } + + /** 전체 쿼터 기능 사용량 조회 */ + getAllUsage(): UsageQuota[] { + const quotaFeatures = [Feature.DICTATION, Feature.LLM_PROCESS] + return quotaFeatures.map((f) => this.getUsage(f)) + } + + /** + * 라이센스 키 활성화. + * 1. LemonSqueezy API로 활성화 시도 + * 2. API 실패 시 로컬 키 검증으로 폴백 (개발/오프라인) + */ + async activate(key: string): Promise { + const trimmedKey = key.trim() + if (!trimmedKey) { + return { success: false, tier: 'free', message: 'License key is empty' } + } + + // 1. LemonSqueezy API 시도 + const apiResult = await this._activateViaLemonSqueezy(trimmedKey) + if (apiResult) { + return apiResult + } + + // 2. API 실패 시 로컬 키 검증 폴백 (개발/테스트용) + logger.info('LemonSqueezy API unreachable, trying local key validation') + const tier = this._validateKeyLocally(trimmedKey) + if (!tier) { + return { success: false, tier: 'free', message: 'Invalid license key' } + } + + const now = Date.now() + this._info = { + ...this._info, + tier, + licenseKey: trimmedKey, + activatedAt: now, + lastVerifiedAt: now, + offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, + } + + this._persistLicenseInfo() + this.emit('tier-changed', this.getInfo()) + logger.info(`License activated (local): tier=${tier}`) + + return { + success: true, + tier, + message: `Successfully activated ${tier} license (offline mode)`, + } + } + + /** 라이센스 비활성화 (Free로 복귀) */ + async deactivate(): Promise { + // LemonSqueezy API로 비활성화 시도 + if (this._info.licenseKey && this._instanceId) { + await this._deactivateViaLemonSqueezy(this._info.licenseKey, this._instanceId) + } + + this._downgradeToFree() + this._instanceId = null + this._writeStoredField('licenseInstanceId', null) + this.emit('tier-changed', this.getInfo()) + logger.info('License deactivated, reverted to free') + } + + /** 업그레이드 유도 이벤트 발생 */ + promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void { + const minTier = FEATURE_MIN_TIER[feature] + const requiredTier: LicenseTier = reason === 'quota_exceeded' ? 'pro' : minTier + + const event: UpgradePromptEvent = { + feature, + reason, + currentTier: this._info.tier, + requiredTier, + quota: reason === 'quota_exceeded' ? this.getUsage(feature) : undefined, + } + + this.emit('upgrade-prompt', event) + } + + /** 티어 비교표 생성 */ + getTierComparison(): TierComparison[] { + return [ + { + feature: Feature.DICTATION, + featureLabel: 'license.feature.dictation', + free: '20/day', + pro: 'unlimited', + proPlus: 'unlimited', + }, + { + feature: Feature.LLM_PROCESS, + featureLabel: 'license.feature.llmProcess', + free: '10/day', + pro: 'unlimited', + proPlus: 'unlimited', + }, + { + feature: Feature.HISTORY_UNLIMITED, + featureLabel: 'license.feature.historyUnlimited', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.LIVE_CAPTION, + featureLabel: 'license.feature.liveCaption', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.SCREEN_CONTEXT, + featureLabel: 'license.feature.screenContext', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.VOICE_MEMO, + featureLabel: 'license.feature.voiceMemo', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.VOICE_COMMAND, + featureLabel: 'license.feature.voiceCommand', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.LLM_CHAIN, + featureLabel: 'license.feature.llmChain', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.CUSTOM_INSTRUCTION_CREATE, + featureLabel: 'license.feature.customInstruction', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.HISTORY_EXPORT, + featureLabel: 'license.feature.historyExport', + free: false, + pro: true, + proPlus: true, + }, + { + feature: Feature.FILE_TRANSCRIPTION, + featureLabel: 'license.feature.fileTranscription', + free: false, + pro: false, + proPlus: true, + }, + { + feature: Feature.VOICE_CONVERSATION, + featureLabel: 'license.feature.voiceConversation', + free: false, + pro: false, + proPlus: true, + }, + { + feature: Feature.MEETING_SUMMARY, + featureLabel: 'license.feature.meetingSummary', + free: false, + pro: false, + proPlus: true, + }, + ] + } + + // ── LemonSqueezy API ─────────────────────────────────── + + /** + * LemonSqueezy API를 통한 라이센스 활성화. + * API 실패 시 null 반환 (폴백 처리를 호출자에게 위임). + */ + private async _activateViaLemonSqueezy(key: string): Promise { + try { + const response = await fetch(`${LEMONSQUEEZY_API}/activate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + license_key: key, + instance_name: this._info.machineId, + }), + signal: AbortSignal.timeout(10000), + }) + + const data = await response.json() as LemonSqueezyActivateResponse + + if (!data.activated || !data.license_key) { + const errorMsg = data.error ?? 'Activation rejected by server' + logger.warn(`LemonSqueezy activation failed: ${errorMsg}`) + return { success: false, tier: 'free', message: errorMsg } + } + + // 키 상태 체크 + if (data.license_key.status === 'expired') { + return { success: false, tier: 'free', message: 'License key has expired' } + } + + // variant_name으로 티어 결정 + const tier = data.meta?.variant_name + ? variantNameToTier(data.meta.variant_name) + : 'pro' + + const now = Date.now() + this._info = { + ...this._info, + tier, + licenseKey: key, + activatedAt: now, + lastVerifiedAt: now, + offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, + } + + // instance_id 저장 (비활성화에 필요) + if (data.instance?.id) { + this._instanceId = data.instance.id + this._writeStoredField('licenseInstanceId', this._instanceId) + } + + this._persistLicenseInfo() + this.emit('tier-changed', this.getInfo()) + logger.info(`License activated via LemonSqueezy: tier=${tier}`) + + return { + success: true, + tier, + message: `Successfully activated ${tier} license`, + } + } catch (err) { + // 네트워크 에러, 타임아웃 등 — null 반환하여 로컬 폴백 + logger.warn(`LemonSqueezy API unreachable: ${err instanceof Error ? err.message : String(err)}`) + return null + } + } + + /** + * LemonSqueezy API를 통한 라이센스 비활성화. + * 실패해도 로컬 상태는 변경 (best-effort). + */ + private async _deactivateViaLemonSqueezy(key: string, instanceId: string): Promise { + try { + await fetch(`${LEMONSQUEEZY_API}/deactivate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + license_key: key, + instance_id: instanceId, + }), + signal: AbortSignal.timeout(10000), + }) + logger.info('License deactivated via LemonSqueezy API') + } catch (err) { + logger.warn(`LemonSqueezy deactivation failed (best-effort): ${err instanceof Error ? err.message : String(err)}`) + } + } + + /** + * LemonSqueezy API를 통한 온라인 검증. + * @returns true if license is valid, false otherwise + */ + private async _validateOnline(): Promise { + if (!this._info.licenseKey) return false + + try { + const response = await fetch(`${LEMONSQUEEZY_API}/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + license_key: this._info.licenseKey, + instance_name: this._info.machineId, + }), + signal: AbortSignal.timeout(10000), + }) + + const data = await response.json() as LemonSqueezyValidateResponse + + if (!data.valid) { + logger.warn(`Online validation failed: ${data.error ?? 'invalid'}`) + + // 키가 expired인 경우 다운그레이드 + if (data.license_key?.status === 'expired') { + logger.warn('License expired, downgrading to free') + this._downgradeToFree() + this.emit('tier-changed', this.getInfo()) + return false + } + + return false + } + + // 검증 성공 — 타임스탬프 갱신 + const now = Date.now() + this._info.lastVerifiedAt = now + this._info.offlineGraceUntil = now + OFFLINE_GRACE_PERIOD_MS + + // variant가 바뀌었을 수 있음 (업/다운그레이드) + if (data.meta?.variant_name) { + const newTier = variantNameToTier(data.meta.variant_name) + if (newTier !== this._info.tier) { + logger.info(`Tier changed via validation: ${this._info.tier} -> ${newTier}`) + this._info.tier = newTier + this.emit('tier-changed', this.getInfo()) + } + } + + this._persistLicenseInfo() + logger.info('Online license validation successful') + return true + } catch (err) { + // 네트워크 에러 — 오프라인 유예 기간 유지 + logger.warn(`Online validation failed (network): ${err instanceof Error ? err.message : String(err)}`) + return false + } + } + + /** + * 주기적 온라인 검증 (초기화 시 호출). + * lastVerifiedAt으로부터 REVERIFY_INTERVAL_MS 이상 경과했으면 비동기 검증. + */ + private _tryPeriodicValidation(): void { + if (!this._info.lastVerifiedAt) return + + const elapsed = Date.now() - this._info.lastVerifiedAt + if (elapsed > REVERIFY_INTERVAL_MS) { + logger.info('Periodic license re-validation needed, attempting...') + // 비동기 실행 (결과를 기다리지 않음 — 초기화 차단 방지) + this._validateOnline().catch((err) => { + logger.warn(`Periodic validation error: ${err instanceof Error ? err.message : String(err)}`) + }) + } + } + + // ── Private helpers ──────────────────────────────────── + + /** + * 로컬 키 검증 (개발/테스트 + 오프라인 패턴). + * 프로덕션에서는 LemonSqueezy API가 우선 사용됨. + * + * 키 포맷 규칙: + * D3RO-PRO-XXXX-XXXX -> pro + * D3RO-PLUS-XXXX-XXXX -> pro_plus + */ + private _validateKeyLocally(key: string): LicenseTier | null { + if (key.startsWith('D3RO-PRO-') && key.length >= 18) { + return 'pro' + } + if (key.startsWith('D3RO-PLUS-') && key.length >= 19) { + return 'pro_plus' + } + return null + } + + private _downgradeToFree(): void { + this._info.tier = 'free' + this._info.licenseKey = null + this._info.activatedAt = null + this._info.lastVerifiedAt = null + this._info.offlineGraceUntil = null + this._persistLicenseInfo() + } + + private _persistLicenseInfo(): void { + this._writeStoredField('licenseTier', this._info.tier) + this._writeStoredField('licenseKey', this._info.licenseKey) + this._writeStoredField('licenseActivatedAt', this._info.activatedAt) + this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt) + this._writeStoredField('licenseOfflineGraceUntil', this._info.offlineGraceUntil) + } + + private _getUsageCount(date: string, feature: Feature): number { + try { + const db = getDatabase() + const rows = db + .select() + .from(dailyUsage) + .where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature))) + .all() + return rows.length > 0 ? rows[0].count : 0 + } catch { + logger.warn(`Failed to get usage count for ${feature}`) + return 0 + } + } + + private _incrementUsage(feature: Feature): void { + try { + const db = getDatabase() + const today = getTodayDate() + + // UPSERT: 있으면 count+1, 없으면 insert + const existing = db + .select() + .from(dailyUsage) + .where(and(eq(dailyUsage.date, today), eq(dailyUsage.feature, feature))) + .all() + + if (existing.length > 0) { + db.update(dailyUsage) + .set({ count: existing[0].count + 1 }) + .where(eq(dailyUsage.id, existing[0].id)) + .run() + } else { + db.insert(dailyUsage) + .values({ date: today, feature, count: 1 }) + .run() + } + } catch (err) { + logger.warn(`Failed to increment usage for ${feature}: ${err}`) + } + } + + // ── 라이센스 전용 파일 기반 저장소 ────────────────────── + // AppConfig에 라이센스 필드가 없으므로 별도 JSON 파일 사용 + + private _licenseStore: Map = new Map() + private _licenseStoreLoaded = false + + private _ensureLicenseStore(): void { + if (this._licenseStoreLoaded) return + try { + const fs = require('fs') as typeof import('fs') + const path = require('path') as typeof import('path') + const electron = require('electron') as typeof import('electron') + const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json') + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record + for (const [k, v] of Object.entries(data)) { + this._licenseStore.set(k, v) + } + } + } catch { + // 파일 없으면 빈 상태로 시작 + } + this._licenseStoreLoaded = true + } + + private _saveLicenseStore(): void { + try { + const fs = require('fs') as typeof import('fs') + const path = require('path') as typeof import('path') + const electron = require('electron') as typeof import('electron') + const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json') + const obj: Record = {} + for (const [k, v] of this._licenseStore.entries()) { + obj[k] = v + } + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf-8') + } catch (err) { + logger.warn(`Failed to save license store: ${err}`) + } + } + + private _readStoredField(key: string): T | null { + this._ensureLicenseStore() + const val = this._licenseStore.get(key) + return (val as T) ?? null + } + + private _writeStoredField(key: string, value: unknown): void { + this._ensureLicenseStore() + this._licenseStore.set(key, value) + this._saveLicenseStore() + } +} + +// ── 싱글톤 ──────────────────────────────────────────────── +let instance: LicenseService | null = null + +export function getLicenseService(): LicenseService { + if (!instance) { + instance = new LicenseService() + } + return instance +} + +export function initLicenseService(): void { + getLicenseService().initialize() +} diff --git a/src/main/services/LocalLLMService.ts b/src/main/services/LocalLLMService.ts index 749a51b..896ded9 100644 --- a/src/main/services/LocalLLMService.ts +++ b/src/main/services/LocalLLMService.ts @@ -290,6 +290,22 @@ class LocalLLMService extends EventEmitter { targetLanguage?: string, customPrompt?: string ): Promise { + // Phase 11: LLM 처리 쿼터 체크 + try { + const { getLicenseService } = await import('./LicenseService') + const { Feature } = await import('@shared/types') + const license = getLicenseService() + const access = license.canUse(Feature.LLM_PROCESS) + if (!access.allowed) { + license.promptUpgrade(Feature.LLM_PROCESS, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required') + // LLM 처리 차단 시 원본 텍스트 반환 (폴백) + return text + } + license.consumeQuota(Feature.LLM_PROCESS) + } catch { + // LicenseService 미초기화 시 허용 + } + let systemPrompt: string if (action === 'custom' && customPrompt) { diff --git a/src/main/services/MemoService.ts b/src/main/services/MemoService.ts new file mode 100644 index 0000000..6d23333 --- /dev/null +++ b/src/main/services/MemoService.ts @@ -0,0 +1,364 @@ +// src/main/services/MemoService.ts +// Phase 10.3: 음성 메모 태그 시스템. 히스토리 항목에 태그를 부착하고 태그별 검색/내보내기를 지원한다. + +import { eq, and, desc, count, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { app } from 'electron' +import path from 'path' +import fs from 'fs' +import { getDatabase } from '../db' +import { memoTags, history } from '../db/schema' +import type { MemoTagRow } from '../db/schema' +import { getLogger } from './LoggerService' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { + MemoTag, + TagCount, + SearchByTagParams, + ExportMemoParams, + HistoryEntry, + HistoryPage +} from '@shared/types' + +const logger = getLogger('MemoService') + +class MemoService { + /** + * 특정 히스토리 항목에 부착된 태그 목록을 조회한다. + */ + getTagsForEntry(historyId: string): MemoTag[] { + const db = getDatabase() + const rows = db + .select() + .from(memoTags) + .where(eq(memoTags.historyId, historyId)) + .orderBy(desc(memoTags.createdAt)) + .all() + + return rows.map((r) => this._toMemoTag(r)) + } + + /** + * 히스토리 항목에 태그를 추가한다. + * 동일 historyId+tag 조합이 이미 존재하면 MemoTagDuplicate 에러를 던진다. + */ + addTag(historyId: string, tag: string): MemoTag { + const db = getDatabase() + const normalizedTag = tag.trim().toLowerCase() + + // 중복 검사 + const existing = db + .select() + .from(memoTags) + .where(and(eq(memoTags.historyId, historyId), eq(memoTags.tag, normalizedTag))) + .get() + + if (existing) { + throw new D3ROError( + ErrorCode.MemoTagDuplicate, + `Tag "${normalizedTag}" already exists for history ${historyId}` + ) + } + + const id = nanoid() + const now = Date.now() + + db.insert(memoTags) + .values({ + id, + historyId, + tag: normalizedTag, + createdAt: now + }) + .run() + + logger.info(`Tag added: "${normalizedTag}" → history ${historyId}`) + + return { + id, + historyId, + tag: normalizedTag, + createdAt: now + } + } + + /** + * 히스토리 항목에서 태그를 제거한다. + * 존재하지 않는 태그이면 MemoTagNotFound 에러를 던진다. + */ + removeTag(historyId: string, tag: string): void { + const db = getDatabase() + const normalizedTag = tag.trim().toLowerCase() + + const result = db + .delete(memoTags) + .where(and(eq(memoTags.historyId, historyId), eq(memoTags.tag, normalizedTag))) + .run() + + if (result.changes === 0) { + throw new D3ROError( + ErrorCode.MemoTagNotFound, + `Tag "${normalizedTag}" not found for history ${historyId}` + ) + } + + logger.info(`Tag removed: "${normalizedTag}" from history ${historyId}`) + } + + /** + * 전체 태그 목록을 사용 횟수 내림차순으로 반환한다. + */ + getAllTags(): TagCount[] { + const db = getDatabase() + const rows = db + .select({ + tag: memoTags.tag, + count: count() + }) + .from(memoTags) + .groupBy(memoTags.tag) + .orderBy(desc(count())) + .all() + + return rows.map((r) => ({ + tag: r.tag, + count: r.count + })) + } + + /** + * 특정 태그가 부착된 히스토리 항목을 페이지네이션으로 조회한다. + */ + searchByTag(params: SearchByTagParams): HistoryPage { + const db = getDatabase() + const { tag, page, pageSize } = params + const normalizedTag = tag.trim().toLowerCase() + + const totalResult = db + .select({ count: count() }) + .from(memoTags) + .innerJoin(history, eq(memoTags.historyId, history.id)) + .where(eq(memoTags.tag, normalizedTag)) + .get() + + const total = totalResult?.count ?? 0 + + const rows = db + .select({ + id: history.id, + originalText: history.originalText, + polishedText: history.polishedText, + focusedApp: history.focusedApp, + focusedAppName: history.focusedAppName, + focusedAppWindowTitle: history.focusedAppWindowTitle, + mode: history.mode, + status: history.status, + errorCode: history.errorCode, + audioLocalPath: history.audioLocalPath, + duration: history.duration, + detectedLanguage: history.detectedLanguage, + micDevice: history.micDevice, + wordCount: history.wordCount, + sttModel: history.sttModel, + llmModel: history.llmModel, + sttLatencyMs: history.sttLatencyMs, + llmLatencyMs: history.llmLatencyMs, + createdAt: history.createdAt, + updatedAt: history.updatedAt, + appVersion: history.appVersion + }) + .from(memoTags) + .innerJoin(history, eq(memoTags.historyId, history.id)) + .where(eq(memoTags.tag, normalizedTag)) + .orderBy(desc(history.createdAt)) + .limit(pageSize) + .offset(page * pageSize) + .all() + + return { + entries: rows.map((r) => this._toHistoryEntry(r)), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize) + } + } + + /** + * 태그+날짜 기준으로 그룹핑된 마크다운 파일을 생성하고 파일 경로를 반환한다. + */ + exportMarkdown(params: ExportMemoParams): string { + const db = getDatabase() + + // 태그별 히스토리 조회 + let tagFilter = params.tag + ? eq(memoTags.tag, params.tag.trim().toLowerCase()) + : undefined + + const dateConditions: ReturnType[] = [] + if (params.from) { + const fromMs = new Date(params.from).getTime() + dateConditions.push(sql`${history.createdAt} >= ${fromMs}`) + } + if (params.to) { + const toMs = new Date(params.to).getTime() + dateConditions.push(sql`${history.createdAt} <= ${toMs}`) + } + + const conditions = [tagFilter, ...dateConditions].filter( + (c): c is NonNullable => c !== undefined + ) + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const rows = db + .select({ + tag: memoTags.tag, + originalText: history.originalText, + polishedText: history.polishedText, + createdAt: history.createdAt, + duration: history.duration + }) + .from(memoTags) + .innerJoin(history, eq(memoTags.historyId, history.id)) + .where(whereClause) + .orderBy(memoTags.tag, desc(history.createdAt)) + .all() + + // 태그별 → 날짜별 그룹핑 + const grouped = new Map>() + for (const row of rows) { + const dateKey = new Date(row.createdAt).toISOString().split('T')[0] + if (!grouped.has(row.tag)) { + grouped.set(row.tag, new Map()) + } + const dateMap = grouped.get(row.tag)! + if (!dateMap.has(dateKey)) { + dateMap.set(dateKey, []) + } + dateMap.get(dateKey)!.push(row) + } + + // 마크다운 생성 + const lines: string[] = [] + const exportDate = new Date().toISOString().split('T')[0] + lines.push(`# Voice Memo Export — ${exportDate}`) + lines.push('') + + if (grouped.size === 0) { + lines.push('No memo entries found.') + } + + for (const [tag, dateMap] of grouped) { + lines.push(`## #${tag}`) + lines.push('') + + for (const [date, entries] of dateMap) { + lines.push(`### ${date}`) + lines.push('') + + for (const entry of entries) { + const time = new Date(entry.createdAt).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit' + }) + const text = entry.polishedText ?? entry.originalText + const durationSec = Math.round(entry.duration) + lines.push(`- **${time}** (${durationSec}s): ${text}`) + } + lines.push('') + } + } + + // 파일 저장 + const exportDir = path.join(app.getPath('userData'), 'exports') + if (!fs.existsSync(exportDir)) { + fs.mkdirSync(exportDir, { recursive: true }) + } + + const timestamp = Date.now() + const tagSuffix = params.tag ? `_${params.tag}` : '' + const filePath = path.join(exportDir, `memo${tagSuffix}_${timestamp}.md`) + + try { + fs.writeFileSync(filePath, lines.join('\n'), 'utf-8') + logger.info(`Memo exported to: ${filePath}`) + return filePath + } catch (err) { + throw new D3ROError( + ErrorCode.MemoExportFailed, + `Failed to export memo: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + + dispose(): void { + logger.info('MemoService disposed') + } + + private _toMemoTag(row: MemoTagRow): MemoTag { + return { + id: row.id, + historyId: row.historyId, + tag: row.tag, + createdAt: row.createdAt + } + } + + private _toHistoryEntry(row: { + id: string + originalText: string + polishedText: string | null + focusedApp: string | null + focusedAppName: string | null + focusedAppWindowTitle: string | null + mode: string + status: string + errorCode: string | null + audioLocalPath: string | null + duration: number + detectedLanguage: string | null + micDevice: string | null + wordCount: number + sttModel: string | null + llmModel: string | null + sttLatencyMs: number | null + llmLatencyMs: number | null + createdAt: number + updatedAt: number + appVersion: string + }): HistoryEntry { + return { + id: row.id, + originalText: row.originalText, + polishedText: row.polishedText, + focusedApp: row.focusedApp, + focusedAppName: row.focusedAppName, + focusedAppWindowTitle: row.focusedAppWindowTitle, + mode: row.mode as HistoryEntry['mode'], + status: row.status as HistoryEntry['status'], + errorCode: row.errorCode, + audioLocalPath: row.audioLocalPath, + duration: row.duration, + detectedLanguage: row.detectedLanguage, + micDevice: row.micDevice, + wordCount: row.wordCount, + sttModel: row.sttModel, + llmModel: row.llmModel, + sttLatencyMs: row.sttLatencyMs, + llmLatencyMs: row.llmLatencyMs, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + appVersion: row.appVersion + } + } +} + +let instance: MemoService | null = null + +export function getMemoService(): MemoService { + if (!instance) { + instance = new MemoService() + } + return instance +} diff --git a/src/main/services/ScreenContextService.ts b/src/main/services/ScreenContextService.ts new file mode 100644 index 0000000..af6dba6 --- /dev/null +++ b/src/main/services/ScreenContextService.ts @@ -0,0 +1,342 @@ +// src/main/services/ScreenContextService.ts +// 활성 윈도우 정보 + 선택된 텍스트를 캡처하여 LLM 프롬프트에 컨텍스트로 제공한다. +// Phase 10.2 스크린 컨텍스트. Speakly ContextService 참조. + +import { clipboard } from 'electron' +import { getLogger } from './LoggerService' +import { configGet, configSet } from './ConfigService' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { ScreenContext, CaptureContextResult } from '@shared/types' + +const logger = getLogger('screen-context') + +// ============================================================ +// nut-js 타입 (lazy import) +// ============================================================ + +interface NutKeyboard { + pressKey: (...keys: number[]) => Promise + releaseKey: (...keys: number[]) => Promise + Key: Record +} + +// ============================================================ +// 클립보드 스냅샷 (TextInsertService와 동일 패턴) +// ============================================================ + +interface ClipboardSnapshot { + text: string | null + html: string | null + image: Electron.NativeImage | null + rtf: string | null + hasContent: boolean +} + +// ============================================================ +// ScreenContextService +// ============================================================ + +class ScreenContextService { + private _nutKeyboard: NutKeyboard | null = null + private _nutLoadPromise: Promise | null = null + + /** + * nut-js lazy dynamic import (TextInsertService 패턴). + */ + private async _ensureNut(): Promise { + if (this._nutKeyboard) return this._nutKeyboard + + if (!this._nutLoadPromise) { + this._nutLoadPromise = (async () => { + try { + const nut = await import('@nut-tree-fork/nut-js') + this._nutKeyboard = { + pressKey: nut.keyboard.pressKey.bind(nut.keyboard), + releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard), + Key: nut.Key, + } + logger.info('nut-js loaded for screen context') + } catch (error) { + logger.error( + `Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}` + ) + throw new D3ROError( + ErrorCode.ContextCaptureFailed, + 'nut-js 로드 실패. 스크린 컨텍스트를 사용할 수 없습니다.' + ) + } + })() + } + + await this._nutLoadPromise + if (!this._nutKeyboard) { + throw new D3ROError(ErrorCode.ContextCaptureFailed, 'nut-js not available') + } + return this._nutKeyboard + } + + // ── 메인 API ────────────────────────────────────────── + + /** + * 활성 윈도우 정보 + 선택된 텍스트를 캡처한다. + * + * @param captureSelectedText true이면 Ctrl+C로 선택 텍스트 캡처 시도 + */ + async captureContext(captureSelectedText = true): Promise { + const capturedAt = Date.now() + let appName: string | null = null + let windowTitle: string | null = null + let selectedText: string | null = null + let selectedTextAttempted = false + + // 1. 활성 윈도우 정보 (PowerShell) + try { + const appInfo = await this._getActiveWindowInfo() + appName = appInfo.appName + windowTitle = appInfo.windowTitle + } catch (error) { + logger.warn( + `Failed to get active window info: ${error instanceof Error ? error.message : String(error)}` + ) + // 활성 윈도우 감지 실패는 치명적이지 않으므로 계속 진행 + } + + // 2. 선택된 텍스트 캡처 (클립보드 방식) + if (captureSelectedText) { + selectedTextAttempted = true + try { + selectedText = await this._captureSelectedText() + } catch (error) { + logger.warn( + `Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}` + ) + // 선택 텍스트 캡처 실패도 치명적이지 않음 + } + } + + const context: ScreenContext = { + appName, + windowTitle, + selectedText, + capturedAt, + } + + logger.info( + `Context captured: app=${appName ?? 'unknown'}, title=${windowTitle ? windowTitle.substring(0, 40) : 'unknown'}, selectedText=${selectedText ? `${selectedText.length} chars` : 'none'}` + ) + + return { context, selectedTextAttempted } + } + + /** + * ScreenContext를 LLM 시스템 프롬프트 접두사로 변환한다. + * 빈 컨텍스트면 빈 문자열을 반환. + */ + buildContextPrompt(ctx: ScreenContext): string { + const parts: string[] = [] + + if (ctx.appName || ctx.windowTitle || ctx.selectedText) { + parts.push('[컨텍스트]') + + if (ctx.appName) { + parts.push(`활성 앱: ${ctx.appName}`) + } + if (ctx.windowTitle) { + parts.push(`윈도우: ${ctx.windowTitle}`) + } + if (ctx.selectedText) { + parts.push(`선택된 텍스트:`) + parts.push(ctx.selectedText) + } + + parts.push('') // 빈 줄로 구분 + } + + return parts.join('\n') + } + + /** + * 스크린 컨텍스트 활성화 여부. + */ + isEnabled(): boolean { + return configGet('screenContextEnabled') + } + + /** + * 스크린 컨텍스트 활성화/비활성화. + */ + setEnabled(enabled: boolean): void { + configSet('screenContextEnabled', enabled) + logger.info(`Screen context ${enabled ? 'enabled' : 'disabled'}`) + } + + dispose(): void { + this._nutKeyboard = null + this._nutLoadPromise = null + logger.info('ScreenContextService disposed') + } + + // ── 내부 구현 ───────────────────────────────────────── + + /** + * Windows에서 활성 윈도우의 프로세스명과 타이틀을 가져온다. + * PowerShell을 사용하여 GetForegroundWindow → 프로세스 정보 조회. + */ + private async _getActiveWindowInfo(): Promise<{ + appName: string | null + windowTitle: string | null + }> { + if (process.platform !== 'win32') { + return { appName: null, windowTitle: null } + } + + const { execFile } = await import('child_process') + const { promisify } = await import('util') + const execFileAsync = promisify(execFile) + + // PowerShell 스크립트: GetForegroundWindow의 프로세스명과 윈도우 타이틀 + const psScript = ` +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; +public class Win32 { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); +} +"@ +$hwnd = [Win32]::GetForegroundWindow() +$pid = 0 +[void][Win32]::GetWindowThreadProcessId($hwnd, [ref]$pid) +$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue +$sb = New-Object System.Text.StringBuilder 512 +[void][Win32]::GetWindowText($hwnd, $sb, 512) +$procName = if ($proc) { $proc.ProcessName } else { '' } +$title = $sb.ToString() +"$procName$([char]10)$title" +`.trim() + + try { + const { stdout } = await execFileAsync('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', 'Bypass', + '-Command', psScript, + ], { timeout: 3000 }) + + const lines = stdout.trim().split('\n') + const appName = lines[0]?.trim() || null + const windowTitle = lines[1]?.trim() || null + + return { appName, windowTitle } + } catch (error) { + logger.warn( + `PowerShell active window query failed: ${error instanceof Error ? error.message : String(error)}` + ) + return { appName: null, windowTitle: null } + } + } + + /** + * 선택된 텍스트를 클립보드 방식으로 캡처한다. + * TextInsertService의 역방향: clipboard save → Ctrl+C simulate → clipboard read → clipboard restore + */ + private async _captureSelectedText(): Promise { + const nut = await this._ensureNut() + + // 1. 기존 클립보드 저장 + const snapshot = this._saveClipboard() + + try { + // 2. 클립보드 비우기 (이전 내용이 남아있으면 "선택 없음"을 감지할 수 없으므로) + clipboard.clear() + + // 3. Ctrl+C 시뮬레이션 + await nut.pressKey(nut.Key.LeftControl, nut.Key.C) + await nut.releaseKey(nut.Key.LeftControl, nut.Key.C) + + // 4. 클립보드에 텍스트가 복사될 때까지 대기 + await this._sleep(150) + + // 5. 클립보드에서 텍스트 읽기 + const text = clipboard.readText() + + // 6. 클립보드 복원 + this._restoreClipboard(snapshot) + + // 빈 문자열이면 선택된 텍스트 없음 + if (!text || text.trim().length === 0) { + return null + } + + return text + } catch (error) { + // 실패 시에도 클립보드 복원 + try { + this._restoreClipboard(snapshot) + } catch { + logger.warn('Failed to restore clipboard after selected text capture error') + } + + throw new D3ROError( + ErrorCode.ContextSelectedTextFailed, + `선택 텍스트 캡처 실패: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + /** + * 클립보드 저장 (TextInsertService와 동일 패턴). + */ + private _saveClipboard(): ClipboardSnapshot { + const text = clipboard.readText() || null + const html = clipboard.readHTML() || null + const rtf = clipboard.readRTF() || null + const image = clipboard.readImage() + const hasImage = image && !image.isEmpty() + + return { + text, + html, + image: hasImage ? image : null, + rtf, + hasContent: !!(text || html || rtf || hasImage), + } + } + + /** + * 클립보드 복원 (TextInsertService와 동일 패턴). + */ + private _restoreClipboard(snapshot: ClipboardSnapshot): void { + if (!snapshot.hasContent) { + clipboard.clear() + return + } + + if (snapshot.text) { + clipboard.writeText(snapshot.text) + } else if (snapshot.html) { + clipboard.writeHTML(snapshot.html) + } else if (snapshot.rtf) { + clipboard.writeRTF(snapshot.rtf) + } else if (snapshot.image) { + clipboard.writeImage(snapshot.image) + } + } + + private _sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +// ── 싱글톤 ──────────────────────────────────────────── + +let instance: ScreenContextService | null = null + +export function getScreenContextService(): ScreenContextService { + if (!instance) { + instance = new ScreenContextService() + } + return instance +} diff --git a/src/main/services/VoiceCommandService.ts b/src/main/services/VoiceCommandService.ts new file mode 100644 index 0000000..9d6e2d8 --- /dev/null +++ b/src/main/services/VoiceCommandService.ts @@ -0,0 +1,335 @@ +// src/main/services/VoiceCommandService.ts +// Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택. +// electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공. + +import { nanoid } from 'nanoid' +import { getLogger } from './LoggerService' +import { configGet, configSet } from './ConfigService' +import type { + VoiceCommandRule, + VoiceCommandKeyword, + VoiceCommandMatch, + KeywordMatchMode +} from '@shared/types' + +const logger = getLogger('voice-command') + +// ============================================================ +// 기본 키워드 (프리셋 명령어용) +// ============================================================ + +interface DefaultKeywordEntry { + instructionId: string + keywords: VoiceCommandKeyword[] + priority: number +} + +const DEFAULT_KEYWORDS: ReadonlyArray = [ + { + instructionId: 'builtin-translate', + keywords: [ + { keyword: '번역해줘', matchMode: 'prefix' }, + { keyword: '번역', matchMode: 'prefix' }, + { keyword: '영어로', matchMode: 'prefix' }, + { keyword: 'translate', matchMode: 'prefix' } + ], + priority: 0 + }, + { + instructionId: 'builtin-summarize', + keywords: [ + { keyword: '요약해줘', matchMode: 'prefix' }, + { keyword: '요약', matchMode: 'prefix' }, + { keyword: 'summarize', matchMode: 'prefix' } + ], + priority: 1 + }, + { + instructionId: 'builtin-formal', + keywords: [ + { keyword: '다듬어줘', matchMode: 'prefix' }, + { keyword: '다듬기', matchMode: 'prefix' }, + { keyword: 'polish', matchMode: 'prefix' } + ], + priority: 2 + }, + { + instructionId: 'builtin-explain-code', + keywords: [ + { keyword: '설명해줘', matchMode: 'prefix' }, + { keyword: '설명', matchMode: 'prefix' }, + { keyword: 'explain', matchMode: 'prefix' } + ], + priority: 3 + } +] + +// ============================================================ +// electron-store 키 (ConfigService와 별도 네임스페이스) +// ============================================================ + +// configGet/configSet에 타입이 없는 키를 사용하므로 as never 캐스팅 필요 +const STORE_KEY_RULES = 'voiceCommandRules' as never +const STORE_KEY_ENABLED = 'voiceCommandsEnabled' as never + +// ============================================================ +// 키워드 매칭 엔진 +// ============================================================ + +/** + * 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다. + * 키워드 앞뒤의 공백/구두점 경계를 존중한다. + */ +function matchKeyword( + text: string, + keyword: string, + mode: KeywordMatchMode +): { matched: boolean; cleanedText: string } { + const trimmed = text.trim() + const lowerText = trimmed.toLowerCase() + const lowerKeyword = keyword.toLowerCase() + + if (lowerKeyword.length === 0) { + return { matched: false, cleanedText: trimmed } + } + + switch (mode) { + case 'prefix': { + if (!lowerText.startsWith(lowerKeyword)) { + return { matched: false, cleanedText: trimmed } + } + // 키워드 뒤가 끝이거나 공백/구두점이어야 정확한 prefix 매칭 + const afterKeyword = trimmed.charAt(keyword.length) + if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) { + return { matched: false, cleanedText: trimmed } + } + const cleaned = trimmed.slice(keyword.length).trimStart() + return { matched: true, cleanedText: cleaned } + } + + case 'suffix': { + if (!lowerText.endsWith(lowerKeyword)) { + return { matched: false, cleanedText: trimmed } + } + // 키워드 앞이 시작이거나 공백/구두점이어야 정확한 suffix 매칭 + const beforeKeyword = trimmed.charAt(trimmed.length - keyword.length - 1) + if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) { + return { matched: false, cleanedText: trimmed } + } + const cleaned = trimmed.slice(0, trimmed.length - keyword.length).trimEnd() + return { matched: true, cleanedText: cleaned } + } + + case 'contains': { + const index = lowerText.indexOf(lowerKeyword) + if (index === -1) { + return { matched: false, cleanedText: trimmed } + } + // contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거 + const before = trimmed.slice(0, index) + const after = trimmed.slice(index + keyword.length) + const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim() + return { matched: true, cleanedText: cleaned } + } + } +} + +function isWordBoundary(char: string): boolean { + // 공백, 구두점, 한국어 조사/어미 앞의 경계 + return /[\s,.!?;:'"()[\]{}\-/]/.test(char) +} + +// ============================================================ +// VoiceCommandService +// ============================================================ + +class VoiceCommandService { + private rules: VoiceCommandRule[] = [] + private enabled = false + private initialized = false + + initialize(): void { + if (this.initialized) return + + this.loadRules() + this.loadEnabled() + this.initialized = true + logger.info( + `VoiceCommandService initialized (${this.rules.length} rules, enabled=${this.enabled})` + ) + } + + /** + * 전사 텍스트에서 키워드를 매칭하여 명령어를 자동 선택한다. + * priority가 낮을수록 높은 우선순위 (0이 가장 높음). + */ + match(text: string): VoiceCommandMatch { + const noMatch: VoiceCommandMatch = { + matched: false, + ruleId: null, + instructionId: null, + cleanedText: text, + matchedKeyword: null + } + + if (!this.enabled) { + return noMatch + } + + const trimmed = text.trim() + if (trimmed.length === 0) { + return noMatch + } + + // priority 오름차순 정렬 (낮은 값 = 높은 우선순위) + const sortedRules = [...this.rules] + .filter((r) => r.enabled && r.keywords.length > 0) + .sort((a, b) => a.priority - b.priority) + + for (const rule of sortedRules) { + for (const kw of rule.keywords) { + const result = matchKeyword(trimmed, kw.keyword, kw.matchMode) + if (result.matched) { + logger.info( + `Voice command matched: rule="${rule.id}", keyword="${kw.keyword}", instruction="${rule.instructionId}"` + ) + return { + matched: true, + ruleId: rule.id, + instructionId: rule.instructionId, + cleanedText: result.cleanedText, + matchedKeyword: kw.keyword + } + } + } + } + + return noMatch + } + + getAllRules(): VoiceCommandRule[] { + return [...this.rules].sort((a, b) => a.priority - b.priority) + } + + setKeywordsForInstruction(instructionId: string, keywords: VoiceCommandKeyword[]): void { + const existingIndex = this.rules.findIndex((r) => r.instructionId === instructionId) + + if (existingIndex !== -1) { + // 기존 rule 업데이트 + this.rules[existingIndex] = { + ...this.rules[existingIndex], + keywords + } + } else { + // 새 rule 생성 + const maxPriority = this.rules.reduce((max, r) => Math.max(max, r.priority), -1) + this.rules.push({ + id: nanoid(), + instructionId, + keywords, + enabled: true, + priority: maxPriority + 1 + }) + } + + this.saveRules() + logger.info( + `Keywords updated for instruction "${instructionId}": ${keywords.length} keywords` + ) + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled + this.saveEnabled() + logger.info(`Voice commands ${enabled ? 'enabled' : 'disabled'}`) + } + + isEnabled(): boolean { + return this.enabled + } + + /** + * 최초 실행 시 기본 키워드를 생성한다. + * 이미 rules가 존재하면 아무 것도 하지 않는다. + */ + initDefaultKeywords(): void { + if (this.rules.length > 0) { + logger.debug('Default keywords already initialized, skipping') + return + } + + for (const entry of DEFAULT_KEYWORDS) { + this.rules.push({ + id: nanoid(), + instructionId: entry.instructionId, + keywords: [...entry.keywords], + enabled: true, + priority: entry.priority + }) + } + + this.saveRules() + logger.info(`Default voice command keywords initialized (${this.rules.length} rules)`) + } + + dispose(): void { + logger.info('VoiceCommandService disposed') + } + + // ── Private ────────────────────────────────────────── + + private loadRules(): void { + try { + const stored = configGet(STORE_KEY_RULES) as VoiceCommandRule[] | undefined + if (Array.isArray(stored) && stored.length > 0) { + this.rules = stored + return + } + } catch { + // 첫 실행 시 키가 없을 수 있음 + } + this.rules = [] + } + + private saveRules(): void { + try { + configSet(STORE_KEY_RULES, this.rules as never) + } catch (error) { + logger.warn( + `Failed to save voice command rules: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private loadEnabled(): void { + try { + const stored = configGet(STORE_KEY_ENABLED) as boolean | undefined + this.enabled = stored === true + } catch { + this.enabled = false + } + } + + private saveEnabled(): void { + try { + configSet(STORE_KEY_ENABLED, this.enabled as never) + } catch (error) { + logger.warn( + `Failed to save voice command enabled state: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} + +// ============================================================ +// 싱글턴 +// ============================================================ + +let instance: VoiceCommandService | null = null + +export function getVoiceCommandService(): VoiceCommandService { + if (!instance) { + instance = new VoiceCommandService() + } + return instance +} diff --git a/src/main/services/VoiceModeService.ts b/src/main/services/VoiceModeService.ts index 7541ed7..cdbebbf 100644 --- a/src/main/services/VoiceModeService.ts +++ b/src/main/services/VoiceModeService.ts @@ -28,6 +28,7 @@ import { sendAudioLevelToTip, showResultPopup, } from '../windows/WindowManager' +import type { ScreenContext } from '@shared/types' const logger = getLogger('VoiceModeService') @@ -45,6 +46,7 @@ interface VoiceSession { transcription: string processedText: string | null accidentalPress: boolean + screenContext: ScreenContext | null } interface VoiceAction { @@ -147,11 +149,18 @@ class VoiceModeService extends EventEmitter { const hotkey = getHotkeyService() this._hotkeyPressHandler = (payload) => { + // Phase 10.1: caption 핫키는 VoiceModeService가 아닌 CaptionService로 라우팅 + if (payload.config.id === 'voice-caption') { + this._toggleCaption() + return + } const mode = this._resolveMode(payload.config) this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id }) } this._hotkeyReleaseHandler = (payload) => { + // caption 핫키의 release는 무시 (토글 방식) + if (payload.config.id === 'voice-caption') return const mode = this._resolveMode(payload.config) this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id }) } @@ -177,6 +186,49 @@ class VoiceModeService extends EventEmitter { return } + // Phase 11: 라이센스 쿼터 체크 + try { + const { getLicenseService } = await import('./LicenseService') + const { Feature } = await import('@shared/types') + const license = getLicenseService() + const access = license.canUse(Feature.DICTATION) + if (!access.allowed) { + license.promptUpgrade(Feature.DICTATION, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required') + logger.warn(`Dictation blocked: ${access.reason}`) + return + } + license.consumeQuota(Feature.DICTATION) + } catch { + // LicenseService 미초기화 시 허용 (graceful) + } + + // Phase 10.1: 자막 모드 활성 중이면 dictation 세션 시작 불가 (AudioCaptureService 공유) + try { + const { getCaptionService } = await import('./CaptionService') + const captionState = getCaptionService().getState() + if (captionState === 'active' || captionState === 'starting') { + logger.warn('Cannot start dictation: caption mode active') + return + } + } catch { + // CaptionService 미초기화 시 무시 + } + + // Phase 10.2: 스크린 컨텍스트 캡처 (녹음 시작 전, 활성 앱 정보 보존) + let screenContext: ScreenContext | null = null + try { + const { getScreenContextService } = await import('./ScreenContextService') + const ctx = getScreenContextService() + if (ctx.isEnabled()) { + const captureSelected = configGet('screenContextEnabled' as keyof import('@shared/types').AppConfig) as unknown as boolean + const result = await ctx.captureContext(captureSelected) + screenContext = result.context + logger.info(`Screen context captured: ${screenContext.appName ?? 'unknown'}`) + } + } catch { + // 컨텍스트 캡처 실패 시 무시 — 핵심 기능 아님 + } + // 세션 생성 this._session = { id: randomUUID(), @@ -187,7 +239,8 @@ class VoiceModeService extends EventEmitter { audioBufferDurationMs: 0, transcription: '', processedText: null, - accidentalPress: false + accidentalPress: false, + screenContext, } this._errorEmitted = false @@ -440,12 +493,32 @@ class VoiceModeService extends EventEmitter { this.emit('transcription-update', { text: result.text, isFinal: true }) + // Phase 10.5: 음성 단축키 — 키워드 매칭으로 LLM 명령어 자동 선택 + let effectiveText = result.text + let overrideAction: string | null = null + let overrideInstructionId: string | null = null + try { + const { getVoiceCommandService } = await import('./VoiceCommandService') + const vcSvc = getVoiceCommandService() + if (vcSvc.isEnabled()) { + const match = vcSvc.match(result.text) + if (match.matched && match.instructionId) { + effectiveText = match.cleanedText + overrideAction = 'custom' + overrideInstructionId = match.instructionId + logger.info(`Voice command matched: keyword="${match.matchedKeyword}", instruction=${match.instructionId}`) + } + } + } catch { + // VoiceCommandService 미초기화 시 무시 + } + // LLM 후처리: none이면 스킵, 그 외에는 LLM 처리 - const llmAction = configGet('defaultLLMAction') + const llmAction = overrideAction ?? configGet('defaultLLMAction') if (llmAction === 'none' || !getLocalLLMService().isAvailable()) { - this._completeSession(result.text) + this._completeSession(effectiveText) } else { - await this._processWithLLM(result.text) + await this._processWithLLM(effectiveText, overrideInstructionId) } } catch (error) { if (this._isInTerminalState()) return @@ -460,26 +533,55 @@ class VoiceModeService extends EventEmitter { // ── LLM 후처리 ───────────────────────────────────────── - private async _processWithLLM(transcribedText: string): Promise { + private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise { if (this._isInTerminalState()) return - // RECOGNIZING 상태 유지 (UI에서 thinking으로 표시됨) try { const llm = getLocalLLMService() const action = configGet('defaultLLMAction') + // Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입 + let contextPrefix = '' + if (this._session?.screenContext) { + try { + const { getScreenContextService } = await import('./ScreenContextService') + contextPrefix = getScreenContextService().buildContextPrompt(this._session.screenContext) + } catch { + // 무시 + } + } + + // Phase 10.4: 체인 모드 처리 + if (action === 'chain') { + try { + const { getChainService } = await import('./ChainService') + const activeChainId = configGet('activeChainId' as keyof import('@shared/types').AppConfig) as unknown as string + if (activeChainId) { + const chainResult = await getChainService().execute(activeChainId, contextPrefix + transcribedText) + if (this._isInTerminalState()) return + if (this._session) this._session.processedText = chainResult.finalText + this._completeSession(chainResult.finalText) + return + } + } catch (error) { + logger.warn(`Chain execution failed, falling back: ${error instanceof Error ? error.message : String(error)}`) + } + } + let processedText: string - if (action === 'custom') { - // 활성 명령어의 프롬프트를 사용 - const activeId = configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string - let customPrompt = transcribedText + // 음성 단축키 오버라이드 또는 활성 명령어 + const effectiveInstructionId = overrideInstructionId + ?? (configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string) - if (activeId) { + if (action === 'custom' || overrideInstructionId) { + let customPrompt = contextPrefix + transcribedText + + if (effectiveInstructionId) { const { getCustomInstructionService } = await import('./CustomInstructionService') - const instruction = getCustomInstructionService().getById(activeId) + const instruction = getCustomInstructionService().getById(effectiveInstructionId) if (instruction) { - customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, transcribedText) + customPrompt = instruction.prompt.replace(/\{\{text\}\}/g, contextPrefix + transcribedText) logger.info(`Using custom instruction: "${instruction.name}"`) } } @@ -487,7 +589,7 @@ class VoiceModeService extends EventEmitter { processedText = await llm.processText(customPrompt, 'custom') } else { logger.info(`Processing with LLM (action: ${action})`) - processedText = await llm.processText(transcribedText, action) + processedText = await llm.processText(contextPrefix + transcribedText, action) } if (this._isInTerminalState()) return @@ -500,7 +602,6 @@ class VoiceModeService extends EventEmitter { } catch (error) { if (this._isInTerminalState()) return logger.warn(`LLM processing failed, using original text: ${error instanceof Error ? error.message : String(error)}`) - // LLM 실패 시 원본 텍스트로 폴백 this._completeSession(transcribedText) } } @@ -702,6 +803,32 @@ class VoiceModeService extends EventEmitter { return 'dictation' } + // ── 자막 모드 토글 (Phase 10.1) ───────────────────────── + + private async _toggleCaption(): Promise { + try { + const { getCaptionService } = await import('./CaptionService') + const caption = getCaptionService() + const state = caption.getState() + + if (state === 'active' || state === 'starting') { + // 자막 활성 중 → 정지 + await caption.stop() + logger.info('Caption stopped via hotkey') + } else { + // dictation 세션이 활성이면 자막 시작 불가 (상호 배제) + if (this._session && !this._isInTerminalState()) { + logger.warn('Cannot start caption: dictation session active') + return + } + await caption.start() + logger.info('Caption started via hotkey') + } + } catch (error) { + logger.error(`Caption toggle failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + // ── 종료 ─────────────────────────────────────────────── dispose(): void { diff --git a/src/main/utils/feature-gate.ts b/src/main/utils/feature-gate.ts new file mode 100644 index 0000000..fbe786d --- /dev/null +++ b/src/main/utils/feature-gate.ts @@ -0,0 +1,72 @@ +// src/main/utils/feature-gate.ts +// Phase 11: Feature gating 유틸리티 +// 서비스에서 기능 사용 전 호출하여 접근 권한 확인 + 쿼터 차단 + +import { getLicenseService } from '../services/LicenseService' +import { D3ROError, ErrorCode } from '@shared/errors' +import type { FeatureAccess } from '@shared/types' +import { Feature } from '@shared/types' + +/** + * 기능 접근 권한을 확인하고, 불가 시 D3ROError를 throw한다. + * 서비스의 진입점에서 호출하여 게이팅한다. + * + * @throws D3ROError(QuotaExceeded) 일일 쿼터 초과 시 + * @throws D3ROError(TierRequired) 티어 부족 시 + */ +export function requireFeature(feature: Feature): void { + const access = getLicenseService().canUse(feature) + if (access.allowed) return + + if (access.reason === 'quota_exceeded') { + getLicenseService().promptUpgrade(feature, 'quota_exceeded') + throw new D3ROError( + ErrorCode.QuotaExceeded, + `Daily quota exceeded for ${feature}. Upgrade to Pro for unlimited access.`, + { + feature, + quota: access.quota, + requiredTier: access.requiredTier, + } + ) + } + + if (access.reason === 'tier_required') { + getLicenseService().promptUpgrade(feature, 'tier_required') + throw new D3ROError( + ErrorCode.TierRequired, + `Feature "${feature}" requires ${access.requiredTier ?? 'pro'} tier.`, + { + feature, + requiredTier: access.requiredTier, + } + ) + } + + // license_expired 등 기타 사유 + throw new D3ROError( + ErrorCode.FeatureNotAvailable, + `Feature "${feature}" is not available: ${access.reason}`, + { feature, reason: access.reason } + ) +} + +/** + * 기능 접근 가능 여부를 확인하고 결과를 반환한다 (throw하지 않음). + * UI에서 버튼 활성/비활성 등 조건부 렌더링에 사용한다. + */ +export function checkFeature(feature: Feature): FeatureAccess { + return getLicenseService().canUse(feature) +} + +/** + * 쿼터가 있는 기능의 사용을 기록한다. + * requireFeature() 통과 후, 실제 작업 성공 시 호출한다. + * + * @throws D3ROError(QuotaExceeded) 쿼터 초과 시 (이중 안전장치) + */ +export function consumeFeature(feature: Feature): void { + getLicenseService().consumeQuota(feature) +} + +export { Feature } diff --git a/src/main/windows/WindowManager.ts b/src/main/windows/WindowManager.ts index 2cafbcf..02e5763 100644 --- a/src/main/windows/WindowManager.ts +++ b/src/main/windows/WindowManager.ts @@ -8,9 +8,25 @@ import { WINDOW_SIZE } from '@shared/constants' import { getLogger } from '../services/LoggerService' import { getIsQuitting } from '../lifecycle' import { configGet } from '../services/ConfigService' +import { buildPopupThemeCss } from '@shared/theme-vars' const logger = getLogger('WindowManager') +// ── 팝업 테마 주입 ──────────────────────────────────────── +/** + * 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입. + * did-finish-load 이후 호출해야 한다. + */ +function injectPopupTheme(win: BrowserWindow): void { + const theme = configGet('theme') as string + // 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가) + const resolvedTheme = theme === 'system' ? 'dark' : theme + const css = buildPopupThemeCss(resolvedTheme) + win.webContents.insertCSS(css).catch((err: unknown) => { + logger.warn('팝업 테마 CSS 주입 실패', err) + }) +} + // ── 윈도우 참조 ─────────────────────────────────────── let mainWindow: BrowserWindow | null = null @@ -18,6 +34,7 @@ let recordingTipWindow: BrowserWindow | null = null let resultPopupWindow: BrowserWindow | null = null let historyPopupWindow: BrowserWindow | null = null let commandPopupWindow: BrowserWindow | null = null +let captionOverlayWindow: BrowserWindow | null = null // ── 메인 윈도우 ─────────────────────────────────────── @@ -97,7 +114,8 @@ function createRecordingTipWindow(): BrowserWindow { preload: join(__dirname, '../preload/popup.js'), sandbox: false, contextIsolation: true, - nodeIntegration: false + nodeIntegration: false, + backgroundThrottling: false } }) @@ -107,6 +125,10 @@ function createRecordingTipWindow(): BrowserWindow { win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html')) } + win.webContents.on('did-finish-load', () => { + injectPopupTheme(win) + }) + win.on('closed', () => { recordingTipWindow = null }) @@ -175,7 +197,7 @@ export function updateRecordingTipState( } export function sendAudioLevelToTip(level: number): void { - if (recordingTipWindow && !recordingTipWindow.isDestroyed() && recordingTipWindow.isVisible()) { + if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { recordingTipWindow.webContents.send('voice:audioLevel', { level }) } } @@ -207,6 +229,10 @@ function createResultPopupWindow(): BrowserWindow { win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html')) } + win.webContents.on('did-finish-load', () => { + injectPopupTheme(win) + }) + win.on('closed', () => { resultPopupWindow = null }) @@ -291,6 +317,10 @@ function createHistoryPopupWindow(): BrowserWindow { win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html')) } + win.webContents.on('did-finish-load', () => { + injectPopupTheme(win) + }) + win.on('closed', () => { historyPopupWindow = null }) @@ -383,6 +413,10 @@ function createCommandPopupWindow(): BrowserWindow { win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html')) } + win.webContents.on('did-finish-load', () => { + injectPopupTheme(win) + }) + win.on('closed', () => { commandPopupWindow = null }) return win } @@ -436,6 +470,82 @@ export function isCommandPopupVisible(): boolean { return !!(commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) } +// ── CaptionOverlay 팝업 (Phase 10.1) ───────────────── + +function createCaptionOverlayWindow(): BrowserWindow { + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + const overlayWidth = Math.round(screenWidth * 0.8) + const overlayHeight = 120 + + const win = new BrowserWindow({ + width: overlayWidth, + height: overlayHeight, + x: Math.round((screenWidth - overlayWidth) / 2), + y: screenHeight - overlayHeight - 40, + show: false, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: false, + webPreferences: { + preload: join(__dirname, '../preload/popup.js'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false + } + }) + + // 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화 + win.setIgnoreMouseEvents(true, { forward: true }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/caption-overlay/index.html`) + } else { + win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html')) + } + + win.webContents.on('did-finish-load', () => { + injectPopupTheme(win) + }) + + win.on('closed', () => { + captionOverlayWindow = null + }) + + return win +} + +export function getCaptionOverlayWindow(): BrowserWindow { + if (!captionOverlayWindow || captionOverlayWindow.isDestroyed()) { + captionOverlayWindow = createCaptionOverlayWindow() + logger.info('CaptionOverlay window created') + } + return captionOverlayWindow +} + +export function showCaptionOverlay(): void { + const win = getCaptionOverlayWindow() + if (!win.isVisible()) { + win.showInactive() + } +} + +export function hideCaptionOverlay(): void { + if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { + captionOverlayWindow.webContents.send('caption:hide', {}) + captionOverlayWindow.hide() + } +} + +export function sendToCaptionOverlay(channel: string, data: unknown): void { + if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { + captionOverlayWindow.webContents.send(channel, data) + } +} + // ── 프리로딩 ────────────────────────────────────────── export function preloadPopupWindows(): void { @@ -446,6 +556,27 @@ export function preloadPopupWindows(): void { logger.info('Popup windows preloaded') } +// ── 테마 재주입 (설정에서 테마 변경 시 호출) ────────── + +/** + * 현재 살아있는 팝업 윈도우에 테마 CSS를 재주입. + * config SET_THEME 핸들러에서 호출한다. + */ +export function reapplyThemeToAllPopups(): void { + const popupWindows = [ + recordingTipWindow, + resultPopupWindow, + historyPopupWindow, + commandPopupWindow, + captionOverlayWindow, + ] + for (const win of popupWindows) { + if (win && !win.isDestroyed()) { + injectPopupTheme(win) + } + } +} + // ── clipboard:copy IPC (ResultPopup에서 사용) ───────── ipcMain.on('clipboard:copy', (_event, text: string) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a840ae..c202f08 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -61,8 +61,42 @@ import type { DictionaryDeleteParams, DictionarySearchParams, StatsSummary, - PermissionStatus + PermissionStatus, + // Phase 10 + MemoTag, + AddTagParams, + RemoveTagParams, + GetTagsParams, + SearchByTagParams, + ExportMemoParams, + TagCount, + VoiceCommandRule, + VoiceCommandMatch, + SetVoiceCommandKeywordsParams, + SetVoiceCommandEnabledParams, + CaptureContextResult, + ScreenContext, + LLMChain, + CreateChainParams, + UpdateChainParams, + DeleteChainParams, + ExecuteChainParams, + ChainExecutionResult, + ChainProgress, + CaptionState, + CaptionSegment, + CaptionConfig, + CaptionSessionSummary, + // Phase 11 + LicenseInfo, + ActivateLicenseParams, + ActivateLicenseResult, + FeatureAccess, + UsageQuota, + UpgradePromptEvent, + TierComparison, } from '@shared/types' +import { Feature } from '@shared/types' import type { IPCResult } from '@shared/errors' type Unsubscribe = () => void @@ -157,6 +191,14 @@ const electronAPI = { invoke(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT), setHandsFreeShortcut: (params: SetHotkeyParams) => invoke(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params), + getCommandShortcut: () => + invoke(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT), + setCommandShortcut: (params: SetHotkeyParams) => + invoke(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, params), + getCaptionShortcut: () => + invoke(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT), + setCaptionShortcut: (params: SetHotkeyParams) => + invoke(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, params), isEnabled: () => invoke(IPC_CHANNELS.HOTKEY.IS_ENABLED), setEnabled: (params: SetEnabledParams) => invoke(IPC_CHANNELS.HOTKEY.SET_ENABLED, params), @@ -251,7 +293,95 @@ const electronAPI = { app: { onDataChanged: (cb: (data: { type: string; activeId?: string }) => void): Unsubscribe => on('app:dataChanged', cb), - } + }, + + // ── Phase 10: Memo Tags ───────────────────────────────── + memo: { + getTags: (params: GetTagsParams) => invoke(IPC_CHANNELS.MEMO.GET_TAGS, params), + addTag: (params: AddTagParams) => invoke(IPC_CHANNELS.MEMO.ADD_TAG, params), + removeTag: (params: RemoveTagParams) => invoke(IPC_CHANNELS.MEMO.REMOVE_TAG, params), + getAllTags: () => invoke(IPC_CHANNELS.MEMO.GET_ALL_TAGS), + searchByTag: (params: SearchByTagParams) => invoke(IPC_CHANNELS.MEMO.SEARCH_BY_TAG, params), + export: (params: ExportMemoParams) => invoke(IPC_CHANNELS.MEMO.EXPORT, params), + }, + + // ── Phase 10: Voice Commands ──────────────────────────── + voiceCommand: { + getAll: () => invoke(IPC_CHANNELS.VOICE_COMMAND.GET_ALL), + setKeywords: (params: SetVoiceCommandKeywordsParams) => invoke(IPC_CHANNELS.VOICE_COMMAND.SET_KEYWORDS, params), + setEnabled: (params: SetVoiceCommandEnabledParams) => invoke(IPC_CHANNELS.VOICE_COMMAND.SET_ENABLED, params), + isEnabled: () => invoke(IPC_CHANNELS.VOICE_COMMAND.IS_ENABLED), + onMatched: (cb: (data: VoiceCommandMatch) => void): Unsubscribe => + on(IPC_CHANNELS.VOICE_COMMAND.MATCHED, cb), + }, + + // ── Phase 10: Screen Context ──────────────────────────── + context: { + capture: (captureSelectedText?: boolean) => invoke(IPC_CHANNELS.CONTEXT.CAPTURE, { captureSelectedText }), + isEnabled: () => invoke(IPC_CHANNELS.CONTEXT.IS_ENABLED), + setEnabled: (params: { enabled: boolean }) => invoke(IPC_CHANNELS.CONTEXT.SET_ENABLED, params), + }, + + // ── Phase 10: LLM Chain ───────────────────────────────── + chain: { + getAll: () => invoke(IPC_CHANNELS.CHAIN.GET_ALL), + create: (params: CreateChainParams) => invoke(IPC_CHANNELS.CHAIN.CREATE, params), + update: (params: UpdateChainParams) => invoke(IPC_CHANNELS.CHAIN.UPDATE, params), + delete: (params: DeleteChainParams) => invoke(IPC_CHANNELS.CHAIN.DELETE, params), + execute: (params: ExecuteChainParams) => invoke(IPC_CHANNELS.CHAIN.EXECUTE, params), + onProgress: (cb: (data: ChainProgress) => void): Unsubscribe => + on(IPC_CHANNELS.CHAIN.PROGRESS, cb), + }, + + // ── Phase 10: Live Caption ────────────────────────────── + caption: { + start: () => invoke(IPC_CHANNELS.CAPTION.START), + stop: () => invoke(IPC_CHANNELS.CAPTION.STOP), + getState: () => invoke(IPC_CHANNELS.CAPTION.GET_STATE), + setConfig: (params: Partial) => invoke(IPC_CHANNELS.CAPTION.SET_CONFIG, params), + getConfig: () => invoke(IPC_CHANNELS.CAPTION.GET_CONFIG), + onSegment: (cb: (data: CaptionSegment) => void): Unsubscribe => + on(IPC_CHANNELS.CAPTION.SEGMENT, cb), + onDelta: (cb: (data: { text: string }) => void): Unsubscribe => + on(IPC_CHANNELS.CAPTION.DELTA, cb), + onStateChanged: (cb: (data: { state: CaptionState }) => void): Unsubscribe => + on(IPC_CHANNELS.CAPTION.STATE_CHANGED, cb), + /** 시스템 오디오 PCM 데이터를 메인에 전달 (렌더러 → 메인) */ + sendSystemAudioData: (data: ArrayBuffer) => + send(IPC_CHANNELS.CAPTION.SYSTEM_AUDIO_DATA, data), + /** 메인에서 시스템 오디오 캡처 시작 요청 수신 */ + onStartSystemAudio: (cb: () => void): Unsubscribe => + on(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, cb), + /** 메인에서 시스템 오디오 캡처 중지 요청 수신 */ + onStopSystemAudio: (cb: () => void): Unsubscribe => + on(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, cb), + /** 시스템 오디오 루프백 활성화 (getDisplayMedia 전에 호출) */ + enableLoopback: () => invoke('system-audio:enable-loopback'), + /** 시스템 오디오 루프백 비활성화 */ + disableLoopback: () => invoke('system-audio:disable-loopback'), + }, + + // ── License (Phase 11) ──────────────────────────────── + license: { + getInfo: () => + invoke(IPC_CHANNELS.LICENSE.GET_INFO), + activate: (params: ActivateLicenseParams) => + invoke(IPC_CHANNELS.LICENSE.ACTIVATE, params), + deactivate: () => + invoke(IPC_CHANNELS.LICENSE.DEACTIVATE), + checkFeature: (feature: Feature) => + invoke(IPC_CHANNELS.LICENSE.CHECK_FEATURE, { feature }), + getUsage: (feature: Feature) => + invoke(IPC_CHANNELS.LICENSE.GET_USAGE, { feature }), + getAllUsage: () => + invoke(IPC_CHANNELS.LICENSE.GET_ALL_USAGE), + getTierComparison: () => + invoke(IPC_CHANNELS.LICENSE.GET_TIER_COMPARISON), + onUpgradePrompt: (cb: (e: UpgradePromptEvent) => void): Unsubscribe => + on(IPC_CHANNELS.LICENSE.UPGRADE_PROMPT, cb), + onTierChanged: (cb: (e: LicenseInfo) => void): Unsubscribe => + on(IPC_CHANNELS.LICENSE.TIER_CHANGED, cb), + }, } as const contextBridge.exposeInMainWorld('electronAPI', electronAPI) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 85edfba..7df1855 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,31 +1,71 @@ // src/renderer/App.tsx — 루트 컴포넌트 // 테마 시스템: auto(시스템) / dark / light. 기본은 auto → 다크. +// i18n: I18nProvider가 전체 트리를 감쌈. ConfigService에서 언어 로드. -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material' import { getTheme } from './theme' +import { I18nProvider } from './i18n' import { AppLayout } from './components/AppLayout' -import type { ThemeMode } from '@shared/types' +import { UpgradePromptModal } from './components/UpgradePromptModal' +import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture' +import type { ThemeMode, ConfigChangedEvent } from '@shared/types' export function App(): React.ReactElement { const [themeMode, setThemeMode] = useState('auto') const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') + const systemAudioCleanupRef = useRef<(() => void) | null>(null) - // 설정에서 테마 로드 + // 설정에서 테마 로드 + 변경 감지 useEffect(() => { window.electronAPI.config.getTheme().then((result) => { if (result.success) { setThemeMode(result.data) } }) + + const unsub = window.electronAPI.config.onChanged((e: ConfigChangedEvent) => { + if (e.key === 'theme') { + setThemeMode(e.value as ThemeMode) + } + }) + return unsub + }, []) + + // 시스템 오디오 캡처: 메인 프로세스의 시작/중지 요청에 응답 + useEffect(() => { + const unsubStart = window.electronAPI.caption.onStartSystemAudio(() => { + startSystemAudioCapture((pcm16Buffer) => { + window.electronAPI.caption.sendSystemAudioData(pcm16Buffer) + }).catch(() => { + // 시스템 오디오 캡처 실패 시 무시 — 마이크 자막만 사용 + }) + }) + + const unsubStop = window.electronAPI.caption.onStopSystemAudio(() => { + stopSystemAudioCapture() + }) + + systemAudioCleanupRef.current = () => { + stopSystemAudioCapture() + } + + return () => { + unsubStart() + unsubStop() + systemAudioCleanupRef.current?.() + } }, []) const theme = useMemo(() => getTheme(themeMode, prefersDark), [themeMode, prefersDark]) return ( - - - - + + + + + + + ) } diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 70aa298..fe3b508 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -14,23 +14,45 @@ import { HistoryPage } from '../pages/HistoryPage' import { DictionaryPage } from '../pages/DictionaryPage' import { CommandsPage } from '../pages/CommandsPage' import { SettingsModal } from './SettingsModal' +import { LicenseModal } from './LicenseModal' import { OnboardingModal } from './OnboardingModal' import { StatusBar } from './StatusBar' -import { d3roPalette, d3roFontMono } from '../theme' +import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../theme' +import { useI18n } from '../i18n' +import type { TranslationKey } from '../i18n' +import type { LicenseTier } from '@shared/types' type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' -const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [ - { route: 'dashboard', label: 'DASH', icon: }, - { route: 'history', label: 'HIST', icon: }, - { route: 'dictionary', label: 'DICT', icon: }, - { route: 'commands', label: 'CMD', icon: }, +interface NavItem { + route: Route + labelKey: TranslationKey + abbr: string + icon: React.ReactElement +} + +const NAV_ITEMS: NavItem[] = [ + { route: 'dashboard', labelKey: 'nav.dashboard', abbr: 'DASH', icon: }, + { route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: }, + { route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: }, + { route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: }, ] +function tierToLedColor(tier: LicenseTier): 'amber' | 'green' { + switch (tier) { + case 'free': return 'amber' + case 'pro': return 'green' + case 'pro_plus': return 'green' + } +} + export function AppLayout(): React.ReactElement { + const { t } = useI18n() const [currentRoute, setCurrentRoute] = useState('dashboard') const [settingsOpen, setSettingsOpen] = useState(false) const [onboardingOpen, setOnboardingOpen] = useState(false) + const [licenseModalOpen, setLicenseModalOpen] = useState(false) + const [currentTier, setCurrentTier] = useState('free') // 첫 실행 감지 useEffect(() => { @@ -44,6 +66,30 @@ export function AppLayout(): React.ReactElement { }) }, []) + // License: load tier + subscribe to changes + listen for open-modal events + useEffect(() => { + window.electronAPI.license.getInfo().then((r) => { + if (r.success) setCurrentTier(r.data.tier) + }) + + const unsubTier = window.electronAPI.license.onTierChanged((info) => { + setCurrentTier(info.tier) + }) + + const unsubUpgrade = window.electronAPI.license.onUpgradePrompt(() => { + setLicenseModalOpen(true) + }) + + const handleOpenLicenseModal = () => setLicenseModalOpen(true) + window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal) + + return () => { + unsubTier() + unsubUpgrade() + window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal) + } + }, []) + return ( @@ -61,16 +107,16 @@ export function AppLayout(): React.ReactElement { gap: 1, }} > - {/* 로고 LED */} + {/* 로고 LED — reflects license tier */} - + D3RO @@ -81,13 +127,13 @@ export function AppLayout(): React.ReactElement { {NAV_ITEMS.map((item) => { const isActive = currentRoute === item.route return ( - + setCurrentRoute(item.route)} sx={{ width: 48, height: 48, - borderRadius: '8px', + borderRadius: d3roRadius.button, display: 'flex', flexDirection: 'column', alignItems: 'center', @@ -96,14 +142,14 @@ export function AppLayout(): React.ReactElement { cursor: 'pointer', bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent', boxShadow: isActive - ? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000' - : '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)', + ? d3roShadow.buttonPressed + : d3roShadow.buttonRaised, color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive, transition: 'all 0.05s linear', transform: isActive ? 'translateY(1px)' : 'none', '&:active': { transform: 'translateY(2px)', - boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)', + boxShadow: d3roShadow.buttonPressed, }, '&:hover': { color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover, @@ -113,13 +159,13 @@ export function AppLayout(): React.ReactElement { {item.icon} - {item.label} + {item.abbr} @@ -130,23 +176,23 @@ export function AppLayout(): React.ReactElement { {/* Settings */} - + setSettingsOpen(true)} sx={{ width: 48, height: 48, - borderRadius: '8px', + borderRadius: d3roRadius.button, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: d3roPalette.text.inactive, - boxShadow: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)', + boxShadow: d3roShadow.buttonRaised, transition: 'all 0.05s linear', '&:active': { transform: 'translateY(2px)', - boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)', + boxShadow: d3roShadow.buttonPressed, }, '&:hover': { color: d3roPalette.text.hover }, }} @@ -175,6 +221,7 @@ export function AppLayout(): React.ReactElement { setSettingsOpen(false)} /> + setLicenseModalOpen(false)} /> setOnboardingOpen(false)} /> ) diff --git a/src/renderer/components/HotkeyRecordModal.tsx b/src/renderer/components/HotkeyRecordModal.tsx index 0533e88..0223abe 100644 --- a/src/renderer/components/HotkeyRecordModal.tsx +++ b/src/renderer/components/HotkeyRecordModal.tsx @@ -16,6 +16,7 @@ import { Typography, } from '@mui/material' import { d3roPalette } from '../theme' +import { useI18n } from '../i18n' import type { HotkeyBinding } from '@shared/types' // ── 키 이름 매핑 (Windows) ────────────────────────────── @@ -75,8 +76,9 @@ export function HotkeyRecordModal({ onClose, onSave, currentBinding, - title = '단축키 설정', + title, }: HotkeyRecordModalProps): React.ReactElement { + const { t } = useI18n() // 현재 눌려있는 키들을 실시간 추적 const [pressedKeys, setPressedKeys] = useState>([]) // 확정된 조합 (녹화 완료 후) @@ -144,14 +146,14 @@ export function HotkeyRecordModal({ const handleSave = () => { if (!captured || captured.length === 0) { - setError('키를 입력해주세요') + setError(t('hotkey.noKey')) return } // 예약 단축키 체크 const label = captured.map(k => k.name).join('+') if (RESERVED_COMBOS.includes(label)) { - setError(`${label}은 시스템 예약 단축키입니다`) + setError(t('hotkey.reserved', { keys: label })) handleReset() return } @@ -195,7 +197,7 @@ export function HotkeyRecordModal({ disableAutoFocus disableRestoreFocus > - {title} + {title ?? t('hotkey.title')} {/* 녹화 영역 */} - 키 조합을 눌러주세요... + {t('hotkey.prompt')} )} @@ -254,7 +256,7 @@ export function HotkeyRecordModal({ {/* 상태 표시 */} {isReady && !error && ( - ✓ {captured?.map(k => k.name).join(' + ')} — 저장을 눌러주세요 + {t('hotkey.ready', { keys: captured?.map(k => k.name).join(' + ') ?? '' })} )} @@ -266,21 +268,21 @@ export function HotkeyRecordModal({ {currentBinding && ( - 현재: {currentBinding.displayLabel} + {t('hotkey.current', { keys: currentBinding.displayLabel })} )} - 조합키(예: Ctrl+Shift+Q) 또는 단일키(예: F5)를 입력하세요 + {t('hotkey.hint')} {isReady && ( )} diff --git a/src/renderer/components/LicenseModal.tsx b/src/renderer/components/LicenseModal.tsx new file mode 100644 index 0000000..21e7037 --- /dev/null +++ b/src/renderer/components/LicenseModal.tsx @@ -0,0 +1,366 @@ +// src/renderer/components/LicenseModal.tsx +// Full-screen license management modal with instrument aesthetic + +import { useState, useEffect, useCallback } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + Box, + TextField, + IconButton, + Divider, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CancelIcon from '@mui/icons-material/Cancel' +import { MetalCard, PhosphorText, Led, ScreenPanel, PhysicalButton } from './ds' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '../theme' +import { useI18n } from '../i18n' +import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@shared/types' + +interface LicenseModalProps { + open: boolean + onClose: () => void +} + +type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off' + +function tierToLedColor(tier: LicenseTier): LedColor { + switch (tier) { + case 'free': return 'amber' + case 'pro': return 'green' + case 'pro_plus': return 'green' + } +} + +function tierToLabel(tier: LicenseTier, t: (k: string) => string): string { + switch (tier) { + case 'free': return t('license.free') + case 'pro': return t('license.pro') + case 'pro_plus': return t('license.proPlus') + } +} + +function maskKey(key: string): string { + if (key.length <= 8) return key + return key.slice(0, 4) + '-****-****-' + key.slice(-4) +} + +function formatDate(timestamp: number | null): string { + if (!timestamp) return '-' + return new Date(timestamp).toLocaleDateString() +} + +export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement { + const { t } = useI18n() + const [licenseInfo, setLicenseInfo] = useState(null) + const [tierComparison, setTierComparison] = useState([]) + const [usageQuotas, setUsageQuotas] = useState([]) + const [keyInput, setKeyInput] = useState('') + const [activating, setActivating] = useState(false) + const [activateMessage, setActivateMessage] = useState(null) + const [activateSuccess, setActivateSuccess] = useState(false) + + const loadData = useCallback(() => { + window.electronAPI.license.getInfo().then((r) => { + if (r.success) setLicenseInfo(r.data) + }) + window.electronAPI.license.getTierComparison().then((r) => { + if (r.success) setTierComparison(r.data) + }) + window.electronAPI.license.getAllUsage().then((r) => { + if (r.success) setUsageQuotas(r.data) + }) + }, []) + + useEffect(() => { + if (open) { + loadData() + setKeyInput('') + setActivateMessage(null) + setActivateSuccess(false) + } + }, [open, loadData]) + + // Subscribe to tier changes + useEffect(() => { + const unsub = window.electronAPI.license.onTierChanged((info) => { + setLicenseInfo(info) + loadData() + }) + return unsub + }, [loadData]) + + const handleActivate = useCallback(async () => { + if (!keyInput.trim()) return + setActivating(true) + setActivateMessage(null) + try { + const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() }) + if (result.success) { + setActivateSuccess(result.data.success) + setActivateMessage( + result.data.success + ? t('license.activated') + : t('license.activateError', { message: result.data.message }), + ) + if (result.data.success) { + loadData() + setKeyInput('') + } + } + } finally { + setActivating(false) + } + }, [keyInput, t, loadData]) + + const handleDeactivate = useCallback(async () => { + await window.electronAPI.license.deactivate() + setActivateMessage(t('license.deactivated')) + setActivateSuccess(false) + loadData() + }, [t, loadData]) + + const isFree = licenseInfo?.tier === 'free' + + return ( + + + {t('license.title')} + + + + + + + {/* ---- Current Tier ---- */} + + + + + {t('license.currentTier')} + + {licenseInfo ? tierToLabel(licenseInfo.tier, t) : '...'} + + + + + + {/* ---- Activate / Info ---- */} + + {isFree ? ( + // Free tier: show activation form + + {t('license.activate')} + + setKeyInput(e.target.value)} + placeholder={t('license.keyPlaceholder')} + size="small" + fullWidth + disabled={activating} + onKeyDown={(e) => { + if (e.key === 'Enter') handleActivate() + }} + sx={{ + '& .MuiOutlinedInput-root': { + fontFamily: d3roFontMono, + fontSize: d3roTypo.compact.size, + bgcolor: d3roPalette.bg.input, + }, + }} + /> + + {activating ? ( + + ) : ( + t('license.activate') + )} + + + {activateMessage && ( + + {activateMessage} + + )} + + ) : ( + // Pro/Pro+: show license info + + {t('license.keyLabel')} + + + + {licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'} + + + + + + + {t('license.activatedAt')} + + {formatDate(licenseInfo?.activatedAt ?? null)} + + + + {t('license.machineId')} + + {licenseInfo?.machineId?.slice(0, 12) ?? '-'}... + + + + + + {t('license.deactivate')} + + + )} + + + {/* ---- Daily Usage ---- */} + {usageQuotas.length > 0 && ( + + {t('license.dailyUsage')} + + {usageQuotas.map((q) => ( + + + {t(`license.feature.${q.feature}` as Parameters[0])} + + + + = q.limit + ? d3roPalette.tag.red + : d3roPalette.accent.amber, + width: q.limit < 0 ? '100%' : `${Math.min(100, (q.used / q.limit) * 100)}%`, + transition: 'width 0.3s ease', + }} + /> + + + + {q.limit < 0 + ? t('license.quotaUnlimited') + : t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })} + + + ))} + + + )} + + {/* ---- Tier Comparison ---- */} + {tierComparison.length > 0 && ( + + {t('license.tierComparison')} + + + + + +   + + + {t('license.free')} + + + {t('license.pro')} + + + {t('license.proPlus')} + + + + + {tierComparison.map((row) => ( + + + {row.featureLabel} + + {renderTierCell(row.free)} + {renderTierCell(row.pro)} + {renderTierCell(row.proPlus)} + + ))} + +
+
+
+ )} +
+
+ ) +} + +function renderTierCell(value: boolean | string): React.ReactElement { + if (typeof value === 'boolean') { + return value ? ( + + ) : ( + + ) + } + return ( + + {value} + + ) +} diff --git a/src/renderer/components/LicenseTab.tsx b/src/renderer/components/LicenseTab.tsx new file mode 100644 index 0000000..9cb4cd7 --- /dev/null +++ b/src/renderer/components/LicenseTab.tsx @@ -0,0 +1,297 @@ +// src/renderer/components/LicenseTab.tsx +// Phase 11: Settings License 탭 — 라이선스 키 입력, 사용량, 티어 비교 + +import { useState, useEffect, useCallback } from 'react' +import { + Box, + Typography, + TextField, + Button, + Divider, + LinearProgress, +} from '@mui/material' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CancelIcon from '@mui/icons-material/Cancel' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme' +import { useI18n } from '../i18n' +import type { + LicenseInfo, + LicenseTier, + UsageQuota, + TierComparison, + ActivateLicenseResult, +} from '@shared/types' + +export function LicenseTab(): React.ReactElement { + const { t } = useI18n() + const [licenseInfo, setLicenseInfo] = useState(null) + const [usage, setUsage] = useState([]) + const [comparison, setComparison] = useState([]) + const [keyInput, setKeyInput] = useState('') + const [activating, setActivating] = useState(false) + const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) + + const loadData = useCallback(() => { + window.electronAPI.license.getInfo().then((r) => { + if (r.success) setLicenseInfo(r.data) + }) + window.electronAPI.license.getAllUsage().then((r) => { + if (r.success) setUsage(r.data) + }) + window.electronAPI.license.getTierComparison().then((r) => { + if (r.success) setComparison(r.data) + }) + }, []) + + useEffect(() => { + loadData() + const unsub = window.electronAPI.license.onTierChanged(() => loadData()) + return unsub + }, [loadData]) + + const handleActivate = useCallback(async () => { + if (!keyInput.trim()) return + setActivating(true) + setMessage(null) + const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() }) + setActivating(false) + if (result.success) { + const data = result.data as ActivateLicenseResult + if (data.success) { + setMessage({ text: t('license.activated'), success: true }) + setKeyInput('') + loadData() + } else { + setMessage({ text: t('license.activateError', { message: data.message }), success: false }) + } + } + }, [keyInput, t, loadData]) + + const handleDeactivate = useCallback(async () => { + await window.electronAPI.license.deactivate() + setMessage({ text: t('license.deactivated'), success: true }) + loadData() + }, [t, loadData]) + + const tierLabel = (tier: LicenseTier): string => { + if (tier === 'pro_plus') return t('license.proPlus') + if (tier === 'pro') return t('license.pro') + return t('license.free') + } + + const tierColor = (tier: LicenseTier): string => { + if (tier === 'pro_plus') return d3roPalette.tag.green + if (tier === 'pro') return d3roPalette.accent.amber + return d3roPalette.text.secondary + } + + return ( + + {/* 현재 플랜 */} + + {t('license.currentTier')} + + + + + {licenseInfo ? tierLabel(licenseInfo.tier) : '...'} + + {licenseInfo?.activatedAt && ( + + {t('license.activatedAt')}: {new Date(licenseInfo.activatedAt).toLocaleDateString()} + + )} + + + + + {/* 라이선스 키 입력 */} + + {t('license.keyLabel')} + + + {licenseInfo?.tier === 'free' ? ( + + setKeyInput(e.target.value)} + disabled={activating} + sx={{ + '& .MuiInputBase-root': { + fontFamily: d3roFontMono, + fontSize: d3roTypo.compact.size, + }, + }} + /> + + + ) : ( + + + {licenseInfo?.licenseKey ? `${licenseInfo.licenseKey.substring(0, 16)}...` : ''} + + + + )} + + {message && ( + + {message.text} + + )} + + + + {/* 일일 사용량 */} + + {t('license.dailyUsage')} + + + {usage.map((q) => ( + + + + {t(`license.feature.${q.feature}` as Parameters[0])} + + + {q.limit === -1 + ? t('license.quotaUnlimited') + : t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })} + + + {q.limit > 0 && ( + = q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber, + borderRadius: d3roRadius.xs, + }, + }} + /> + )} + + ))} + + + + {/* 티어 비교표 */} + + {t('license.tierComparison')} + + + + + + {''} + {t('license.free')} + {t('license.pro')} + {t('license.proPlus')} + + + + {comparison.map((row) => ( + + {t(`license.feature.${row.feature}`)} + + + + + ))} + + + + {/* 기기 ID */} + {licenseInfo && ( + + {t('license.machineId')}: {licenseInfo.machineId.substring(0, 16)}... + + )} + + ) +} + +function TierCell({ value }: { value: boolean | string }): React.ReactElement { + if (value === true) { + return + } + if (value === false) { + return + } + return ( + + {value} + + ) +} diff --git a/src/renderer/components/OllamaGuideModal.tsx b/src/renderer/components/OllamaGuideModal.tsx index 7f62960..4a626ae 100644 --- a/src/renderer/components/OllamaGuideModal.tsx +++ b/src/renderer/components/OllamaGuideModal.tsx @@ -15,8 +15,9 @@ import { import CloseIcon from '@mui/icons-material/Close' import OpenInNewIcon from '@mui/icons-material/OpenInNew' import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import { d3roPalette, d3roFontMono } from '../theme' +import { d3roPalette, d3roFontMono, d3roShadow } from '../theme' import { Led } from './ds' +import { useI18n } from '../i18n' interface OllamaGuideModalProps { open: boolean @@ -36,7 +37,7 @@ function CodeBlock({ children }: { children: string }): React.ReactElement { display: 'flex', alignItems: 'center', justifyContent: 'space-between', - boxShadow: 'inset 0 1px 4px rgba(0,0,0,0.3)', + boxShadow: d3roShadow.inset, }} > {children} @@ -52,6 +53,8 @@ function CodeBlock({ children }: { children: string }): React.ReactElement { } export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement { + const { t } = useI18n() + const handleOpenLink = (url: string) => { window.electronAPI.system.openExternal({ url }) } @@ -83,7 +86,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac py: 1.5, }} > - OLLAMA SETUP GUIDE + {t('ollama.title')} @@ -96,11 +99,11 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac - STEP 1 — Ollama 설치 + {t('ollama.step1.title')} - Ollama는 로컬에서 LLM을 실행하는 무료 도구입니다. + {t('ollama.step1.desc')} diff --git a/src/renderer/components/OnboardingModal.tsx b/src/renderer/components/OnboardingModal.tsx index 75e06ae..3c6c14d 100644 --- a/src/renderer/components/OnboardingModal.tsx +++ b/src/renderer/components/OnboardingModal.tsx @@ -15,9 +15,10 @@ import MicIcon from '@mui/icons-material/Mic' import KeyboardIcon from '@mui/icons-material/Keyboard' import CheckCircleIcon from '@mui/icons-material/CheckCircle' import OpenInNewIcon from '@mui/icons-material/OpenInNew' -import { d3roPalette, d3roFontMono } from '../theme' +import { d3roPalette, d3roFontMono, d3roShadow } from '../theme' import { Led } from './ds' import { HotkeyRecordModal } from './HotkeyRecordModal' +import { useI18n } from '../i18n' import type { HotkeyBinding, AudioDevice } from '@shared/types' interface OnboardingModalProps { @@ -26,6 +27,7 @@ interface OnboardingModalProps { } export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { + const { t } = useI18n() const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: 완료 const [devices, setDevices] = useState([]) const [selectedDevice, setSelectedDevice] = useState('default') @@ -66,7 +68,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. bgcolor: d3roPalette.bg.chassis, backgroundImage: 'none', border: `1px solid ${d3roPalette.border.subtle}`, - boxShadow: '0 40px 80px rgba(0,0,0,0.8)', + boxShadow: d3roShadow.chassis, }, }} > @@ -88,10 +90,10 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. D3RO-VOICE - 타이핑 없이, 음성으로. 로컬 AI 음성 어시스턴트입니다. + {t('onboarding.welcome.desc')}
)} @@ -102,11 +104,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. - 마이크 설정 + {t('onboarding.mic.title')} - 사용할 마이크를 선택하세요. 나중에 설정에서 변경할 수 있습니다. + {t('onboarding.mic.desc')} {devices.map((d, idx) => ( @@ -128,14 +130,14 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. }} > - {d.label}{d.isDefault ? ' (기본)' : ''} + {d.label}{d.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
))} - - + + )} @@ -146,18 +148,18 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. - 단축키 설정 + {t('onboarding.hotkey.title')} - 받아쓰기 단축키를 설정하세요. 키를 누르고 있는 동안 녹음됩니다. + {t('onboarding.hotkey.desc')} ) : ( - 단축키가 설정되지 않았습니다 + {t('onboarding.hotkey.notSet')} )} @@ -191,11 +193,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. onClick={() => setHotkeyModalOpen(true)} sx={{ mb: 3, fontFamily: d3roFontMono }} > - {hotkeyBinding ? '단축키 변경' : '단축키 설정'} + {hotkeyBinding ? t('onboarding.hotkey.change') : t('onboarding.hotkey.set')} - - + + )} @@ -206,12 +208,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. - Ollama 설치 (선택) + {t('onboarding.ollama.title')} - 번역, 요약 등 LLM 후처리를 사용하려면 Ollama가 필요합니다. - 음성 받아쓰기 자체는 Ollama 없이도 동작합니다. + {t('onboarding.ollama.desc')} - + $ ollama pull qwen3:4b - 설치 후 터미널에서 모델을 다운로드하세요 + {t('onboarding.ollama.modelHint')} - - + + )} @@ -242,15 +243,15 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. - 설정 완료! + {t('onboarding.done.title')} {hotkeyBinding - ? `${hotkeyBinding.displayLabel} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.` - : '설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.'} + ? t('onboarding.done.descWithKey', { key: hotkeyBinding.displayLabel }) + : t('onboarding.done.descNoKey')} )} @@ -262,7 +263,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. onClose={() => setHotkeyModalOpen(false)} onSave={handleHotkeySave} currentBinding={hotkeyBinding} - title="받아쓰기 단축키 설정" + title={t('hotkey.dictationTitle')} /> ) diff --git a/src/renderer/components/ProBadge.tsx b/src/renderer/components/ProBadge.tsx new file mode 100644 index 0000000..b303ce3 --- /dev/null +++ b/src/renderer/components/ProBadge.tsx @@ -0,0 +1,78 @@ +// src/renderer/components/ProBadge.tsx +// Feature gate badge: renders children normally if unlocked, +// shows lock overlay with PRO badge if locked. + +import { Box, Typography } from '@mui/material' +import LockIcon from '@mui/icons-material/Lock' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme' +import { useProFeature } from '../hooks/useProFeature' +import { useI18n } from '../i18n' +import type { Feature } from '@shared/types' + +interface ProBadgeProps { + feature: Feature + children: React.ReactNode +} + +export function ProBadge({ feature, children }: ProBadgeProps): React.ReactElement { + const { t } = useI18n() + const { unlocked, loading, showUpgrade } = useProFeature(feature) + + // While loading or if unlocked, render children normally + if (loading || unlocked) { + return <>{children} + } + + return ( + + {/* Children rendered with reduced opacity */} + + {children} + + + {/* Lock overlay */} + + + + + {t('license.pro.required')} + + + + + ) +} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index ca98b1f..ac3876b 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -28,9 +28,16 @@ import CloseIcon from '@mui/icons-material/Close' import KeyboardIcon from '@mui/icons-material/Keyboard' import EditIcon from '@mui/icons-material/Edit' import MicIcon from '@mui/icons-material/Mic' -import { d3roPalette, d3roFontMono } from '../theme' +import LockIcon from '@mui/icons-material/Lock' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CancelIcon from '@mui/icons-material/Cancel' +import { d3roPalette, d3roFontMono, d3roShadow } from '../theme' import { HotkeyRecordModal } from './HotkeyRecordModal' +import { LicenseTab } from './LicenseTab' +import { useI18n, LOCALE_META } from '../i18n' +import type { Locale } from '../i18n' import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice } from '@shared/types' +import { Feature } from '@shared/types' interface SettingsModalProps { open: boolean @@ -53,10 +60,12 @@ function HotkeyDisplay({ binding, onEdit, label, + notSetLabel, }: { binding: HotkeyBinding | null onEdit: () => void label: string + notSetLabel: string }): React.ReactElement { return ( @@ -84,7 +93,7 @@ function HotkeyDisplay({ ) : ( - 미설정 + {notSetLabel} )} @@ -101,6 +110,8 @@ function VoiceModeCard({ enabled, onToggle, disabled, + enabledLabel, + disabledLabel, children, }: { title: string @@ -108,6 +119,8 @@ function VoiceModeCard({ enabled: boolean onToggle: (enabled: boolean) => void disabled?: boolean + enabledLabel: string + disabledLabel: string children?: React.ReactNode }): React.ReactElement { return ( @@ -118,7 +131,7 @@ function VoiceModeCard({ bgcolor: d3roPalette.bg.inset, borderRadius: '10px', border: 'none', - boxShadow: `inset 0 2px 6px rgba(0,0,0,0.4), 0 1px 1px rgba(255,255,255,0.04)`, + boxShadow: d3roShadow.inset, }} > @@ -147,7 +160,7 @@ function VoiceModeCard({ } label={ >({}) const [loading, setLoading] = useState(true) @@ -182,11 +196,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac const [dictationBinding, setDictationBinding] = useState(null) const [handsFreeEnabled, setHandsFreeEnabled] = useState(false) const [handsFreeBinding, setHandsFreeBinding] = useState(null) + const [captionBinding, setCaptionBinding] = useState(null) const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true) // 핫키 녹화 모달 const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false) - const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree'>('dictation') + const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation') // 오디오 디바이스 const [audioDevices, setAudioDevices] = useState([]) @@ -199,17 +214,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac if (!open) return setLoading(true) - // 빠른 로드: 설정+핫키 먼저 (즉시 표시) Promise.all([ window.electronAPI.config.getAll(), window.electronAPI.hotkey.getDictationShortcut(), window.electronAPI.hotkey.getHandsFreeShortcut(), + window.electronAPI.hotkey.getCaptionShortcut(), window.electronAPI.hotkey.isEnabled(), ]) - .then(([configResult, dictResult, hfResult, enabledResult]) => { + .then(([configResult, dictResult, hfResult, capResult, enabledResult]) => { if (configResult.success) setConfig(configResult.data) if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data) if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data) + if (capResult.success && capResult.data) setCaptionBinding(capResult.data) if (enabledResult.success) { setHotkeyGlobalEnabled(enabledResult.data) setDictationEnabled(enabledResult.data) @@ -217,7 +233,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac }) .finally(() => setLoading(false)) - // 느린 로드: 오디오 디바이스 (백그라운드, UI 블로킹 안 함) Promise.all([ window.electronAPI.audio.getDevices(), window.electronAPI.audio.getSelectedDevice(), @@ -232,12 +247,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac window.electronAPI.config.set({ key, value }) }, []) - // 음성 모드 토글 const handleDictationToggle = useCallback( (enabled: boolean) => { setDictationEnabled(enabled) window.electronAPI.hotkey.setEnabled({ enabled }) - // 받아쓰기 비활성화 시 핸즈프리도 비활성화 if (!enabled && handsFreeEnabled) { setHandsFreeEnabled(false) } @@ -247,7 +260,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac const handleHandsFreeToggle = useCallback((enabled: boolean) => { if (enabled && !handsFreeBinding) { - // 핫키 설정 없으면 녹화 모달 열기 setHotkeyModalTarget('handsFree') setHotkeyModalOpen(true) return @@ -255,7 +267,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac setHandsFreeEnabled(enabled) }, [handsFreeBinding]) - // 핫키 저장 const handleHotkeySave = useCallback( (binding: HotkeyBinding) => { if (hotkeyModalTarget === 'dictation') { @@ -263,20 +274,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac window.electronAPI.hotkey.setDictationShortcut({ binding }) setDictationEnabled(true) window.electronAPI.hotkey.setEnabled({ enabled: true }) - } else { + } else if (hotkeyModalTarget === 'handsFree') { setHandsFreeBinding(binding) window.electronAPI.hotkey.setHandsFreeShortcut({ binding }) setHandsFreeEnabled(true) + } else { + setCaptionBinding(binding) + window.electronAPI.hotkey.setCaptionShortcut({ binding }) } }, [hotkeyModalTarget] ) - const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree') => { + const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree' | 'caption') => { setHotkeyModalTarget(target) setHotkeyModalOpen(true) }, []) + const handleLanguageChange = useCallback((newLocale: string) => { + setLocale(newLocale as Locale) + setConfig((prev) => ({ ...prev, language: newLocale })) + }, [setLocale]) + if (loading) return return ( @@ -292,7 +311,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac bgcolor: d3roPalette.bg.chassis, backgroundImage: 'none', border: `1px solid ${d3roPalette.border.subtle}`, - boxShadow: `0 40px 80px -20px rgba(0,0,0,0.8), inset 0 1px 1px rgba(255,255,255,0.08)`, + boxShadow: d3roShadow.chassis, }, }} > @@ -310,7 +329,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac py: 1.5, }} > - Settings + {t('settings.title')} @@ -340,143 +359,145 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 }, }} > - } iconPosition="start" /> - } iconPosition="start" /> - - - + } iconPosition="start" /> + } iconPosition="start" /> + + + } iconPosition="start" /> + {/* ── 일반 탭 ─────────────────────────────── */} - {/* 단축키 섹션 */} - 단축키 + {t('settings.shortcuts')} - {/* 받아쓰기 모드 */} openHotkeyModal('dictation')} - label="키" + label={t('settings.key')} + notSetLabel={t('settings.notSet')} /> - {/* Agent 모드 (더블프레스) */} - {/* 원터치 모드 (핸즈프리) */} openHotkeyModal('handsFree')} - label="키" + label={t('settings.key')} + notSetLabel={t('settings.notSet')} + /> + + + + openHotkeyModal('caption')} + label={t('settings.key')} + notSetLabel={t('settings.notSet')} /> - {/* UI 설정 */} - 인터페이스 + {t('settings.interface')} - 테마 + {t('settings.theme')} - 언어 + {t('settings.language')} - {/* 앱 동작 */} - 앱 동작 + {t('settings.appBehavior')} updateConfig('closeToTray', e.target.checked)} - /> - } - label="트레이로 최소화" + control={ updateConfig('closeToTray', e.target.checked)} />} + label={t('settings.closeToTray')} /> - updateConfig('autoLaunch', e.target.checked)} - /> - } - label="시스템 시작 시 자동 실행" + control={ updateConfig('autoLaunch', e.target.checked)} />} + label={t('settings.autoLaunch')} /> - updateConfig('autoInsert', e.target.checked)} - /> - } - label="전사 후 자동 텍스트 삽입" + control={ updateConfig('autoInsert', e.target.checked)} />} + label={t('settings.autoInsert')} /> - updateConfig('soundEnabled', e.target.checked)} - /> - } - label="효과음" + control={ updateConfig('soundEnabled', e.target.checked)} />} + label={t('settings.soundEffects')} /> @@ -485,13 +506,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac - 마이크 + {t('settings.microphone')} - 입력 장치 + {t('settings.inputDevice')} - {/* 마이크 테스트 */} - - 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber, - borderRadius: '4px', - transition: 'width 100ms ease-out', - }} - /> + + 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber, borderRadius: '4px', transition: 'width 100ms ease-out' }} /> - 텍스트 삽입 + {t('settings.captionAudio')} - 삽입 방식 + {t('settings.captionSource')} + + + + + + {t('settings.textInsert')} + + + + {t('settings.insertMethod')} + @@ -586,28 +605,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac - Whisper 모델 + {t('settings.whisperModel')} - 인식 언어 + {t('settings.sttLanguage')} updateConfig('defaultLLMAction', e.target.value)} > - 없음 (원본 텍스트 그대로) - 다듬기 (문법+자연스러움) - 번역 - 요약 - 문법 교정 - 커스텀 프롬프트 + {t('settings.action.none')} + {t('settings.action.refine')} + {t('settings.action.translate')} + {t('settings.action.summarize')} + {t('settings.action.grammar')} + {t('settings.action.custom')} - 핫키로 녹음 후 전사된 텍스트에 선택한 LLM 후처리가 적용됩니다. - Ollama가 연결되어 있을 때만 동작합니다. + {t('settings.actionHint')} + + + + {/* Phase 10: 음성 명령어 토글 */} + + {t('settings.voiceCommands')} + + )['voiceCommandsEnabled'] as boolean ?? false} + onChange={async (_, checked) => { + updateConfig('voiceCommandsEnabled' as keyof AppConfig, checked as never) + await window.electronAPI.voiceCommand.setEnabled({ enabled: checked }) + }} + size="small" + /> + } + label={ + + {t('settings.voiceCommands')} + {t('settings.voiceCommands.desc')} + + } + /> + + {/* Phase 10: 화면 컨텍스트 토글 */} + + {t('settings.screenContext')} + + { + updateConfig('screenContextEnabled', checked) + await window.electronAPI.context.setEnabled({ enabled: checked }) + }} + size="small" + /> + } + label={ + + {t('settings.screenContext')} + {t('settings.screenContext.desc')} + + } + /> - {/* ── 정보 탭 ──────────────────────────────── */} + {/* ── 라이선스 탭 ────────────────────────────── */} + + + + {/* ── 정보 탭 ──────────────────────────────── */} + D3RO-VOICE - 버전 + {t('settings.about.version')} v1.0.0 - 기술 스택 - - Electron + React 19 + MUI 7 + TypeScript - + {t('settings.about.techStack')} + {t('settings.about.techStackValue')} - 음성 엔진 - - STT: faster-whisper (로컬) / LLM: Ollama (로컬) - + {t('settings.about.voiceEngine')} + {t('settings.about.voiceEngineValue')} - Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. - 클라우드 의존성 없이 완전 로컬로 동작합니다. + {t('settings.about.description')} @@ -721,14 +784,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac - {/* 핫키 녹화 모달 */} setHotkeyModalOpen(false)} onSave={handleHotkeySave} - currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding} - title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'} + currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding} + title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')} /> ) } + diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx index 9f5827e..2b65d69 100644 --- a/src/renderer/components/StatusBar.tsx +++ b/src/renderer/components/StatusBar.tsx @@ -1,37 +1,45 @@ // src/renderer/components/StatusBar.tsx // 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 + Ollama 오프라인 넛징 +// 타이포 토큰 적용 (d3roTypo SSOT) import { useState, useEffect } from 'react' import { Box, Typography, Fade, IconButton } from '@mui/material' import CloseIcon from '@mui/icons-material/Close' import { Led } from './ds' -import { d3roPalette, d3roFontMono } from '../theme' +import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../theme' import { OllamaGuideModal } from './OllamaGuideModal' +import { useI18n } from '../i18n' import type { LLMStatus } from '@shared/types' export function StatusBar(): React.ReactElement { + const { t } = useI18n() const [llmStatus, setLlmStatus] = useState(null) const [showNudge, setShowNudge] = useState(false) const [guideOpen, setGuideOpen] = useState(false) + const [captionActive, setCaptionActive] = useState(false) + + // 자막 상태 구독 + useEffect(() => { + window.electronAPI.caption.getState().then((r) => { + if (r.success) setCaptionActive(r.data === 'active') + }) + const unsub = window.electronAPI.caption.onStateChanged((data) => { + setCaptionActive(data.state === 'active') + }) + return unsub + }, []) useEffect(() => { + // 초기 상태 로드 + 이벤트 구독 (폴링 불필요 — onStatusChanged로 실시간 갱신) window.electronAPI.llm.getStatus().then((r) => { if (r.success) setLlmStatus(r.data) }) - const unsub = window.electronAPI.llm.onStatusChanged((e) => setLlmStatus(e.status)) - const interval = setInterval(() => { - window.electronAPI.llm.getStatus().then((r) => { - if (r.success) setLlmStatus(r.data) - }) - }, 5000) - - return () => { unsub(); clearInterval(interval) } + return unsub }, []) const connected = llmStatus?.connectionState === 'connected' - // 오프라인 3초 후 넛징 버블 표시 → 10초 후 자동 숨김 useEffect(() => { if (!connected) { const showTimer = setTimeout(() => setShowNudge(true), 3000) @@ -66,13 +74,12 @@ export function StatusBar(): React.ReactElement { left: 8, bgcolor: d3roPalette.bg.card, border: `1px solid ${d3roPalette.border.default}`, - borderRadius: '10px', + borderRadius: d3roRadius.button, px: 2, py: 1.5, - boxShadow: '0 8px 24px rgba(0,0,0,0.4)', + boxShadow: d3roShadow.tooltip, maxWidth: 280, zIndex: 100, - // 말풍선 삼각형 '&::after': { content: '""', position: 'absolute', @@ -89,27 +96,27 @@ export function StatusBar(): React.ReactElement { }} > - - Ollama가 실행되지 않고 있어요 + + {t('status.nudge.title')} setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 1 }}> - - LLM 후처리(번역, 요약 등)를 사용하려면 Ollama가 필요합니다. + + {t('status.nudge.desc')} { setGuideOpen(true); setShowNudge(false) }} sx={{ - fontSize: '11px', + fontSize: d3roTypo.meta.size, color: d3roPalette.accent.amber, - fontWeight: 700, + fontWeight: d3roTypo.label.weight, cursor: 'pointer', '&:hover': { textDecoration: 'underline' }, }} > - 설치 안내 보기 → + {t('status.nudge.guide')} @@ -120,27 +127,54 @@ export function StatusBar(): React.ReactElement { onClick={() => !connected && setGuideOpen(true)} sx={{ fontFamily: d3roFontMono, - fontSize: '9px', + fontSize: d3roTypo.engrave.size, color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red, - letterSpacing: '1px', - fontWeight: 700, + letterSpacing: d3roTypo.engrave.spacing, + fontWeight: d3roTypo.engrave.weight, cursor: connected ? 'default' : 'pointer', '&:hover': connected ? {} : { textDecoration: 'underline' }, }} > - {connected ? 'OLLAMA' : 'OFFLINE'} + {connected ? t('status.ollama') : t('status.offline')} {llmStatus?.activeModel && ( - + {llmStatus.activeModel.toUpperCase()} )} + {/* 자막 상태 표시 */} + {captionActive && ( + + + + CAPTION + + + )} + - + PRECISION DATA LINK diff --git a/src/renderer/components/UpgradePromptModal.tsx b/src/renderer/components/UpgradePromptModal.tsx new file mode 100644 index 0000000..129cc4b --- /dev/null +++ b/src/renderer/components/UpgradePromptModal.tsx @@ -0,0 +1,227 @@ +// src/renderer/components/UpgradePromptModal.tsx +// Phase 11: 업그레이드 유도 모달 — 쿼터 소진 또는 잠긴 기능 접근 시 표시 + +import { useState, useEffect, useCallback } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + LinearProgress, +} from '@mui/material' +import LockIcon from '@mui/icons-material/Lock' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '../theme' +import { useI18n } from '../i18n' +import type { UpgradePromptEvent, UsageQuota } from '@shared/types' + +export function UpgradePromptModal(): React.ReactElement { + const { t } = useI18n() + const [open, setOpen] = useState(false) + const [event, setEvent] = useState(null) + + useEffect(() => { + const unsub = window.electronAPI.license.onUpgradePrompt((e) => { + setEvent(e) + setOpen(true) + }) + return unsub + }, []) + + // 라이센스 모달 열기 이벤트와 연동 + const handleLearnMore = useCallback(() => { + setOpen(false) + window.dispatchEvent(new CustomEvent('d3ro:open-license-modal')) + }, []) + + const handleClose = useCallback(() => { + setOpen(false) + }, []) + + if (!event) return <> + + const isQuota = event.reason === 'quota_exceeded' + const featureLabel = t(`license.feature.${event.feature}`) + const tierLabel = event.requiredTier === 'pro_plus' ? t('license.proPlus') : t('license.pro') + + return ( + + + + {isQuota + ? t('license.quotaExceeded.title', { feature: featureLabel }) + : t('license.tierRequired.title', { feature: featureLabel, tier: tierLabel })} + + + + + {isQuota + ? t('license.quotaExceeded.desc') + : t('license.tierRequired.desc', { tier: tierLabel })} + + + {/* 쿼터 바 */} + {isQuota && event.quota && ( + + )} + + {/* 혜택 목록 */} + + + {t('license.upgradeBenefits')} + + {[ + t('license.benefit.unlimitedDictation'), + t('license.benefit.unlimitedLLM'), + t('license.benefit.liveCaption'), + t('license.benefit.unlimitedHistory'), + ].map((benefit) => ( + + + + {benefit} + + + ))} + + + + + + + + + ) +} + +// ── 쿼터 바 서브 컴포넌트 ────────────────────────────────── + +function QuotaBar({ quota }: { quota: UsageQuota }): React.ReactElement { + const { t } = useI18n() + const progress = quota.limit > 0 ? (quota.used / quota.limit) * 100 : 100 + const featureLabel = t(`license.feature.${quota.feature}`) + + return ( + + + + {featureLabel} + + + {t('license.quotaUsed', { + used: String(quota.used), + limit: String(quota.limit), + })} + + + = 100 ? d3roPalette.tag.red : d3roPalette.accent.amber, + borderRadius: d3roRadius.xs, + }, + }} + /> + + ) +} diff --git a/src/renderer/components/ds/ButtonGroup.tsx b/src/renderer/components/ds/ButtonGroup.tsx new file mode 100644 index 0000000..b603b72 --- /dev/null +++ b/src/renderer/components/ds/ButtonGroup.tsx @@ -0,0 +1,30 @@ +// src/renderer/components/ds/ButtonGroup.tsx +// 시안 A: 인셋 버튼 클러스터 — 레퍼런스의 .button-group 패턴 +// 물리 버튼들을 인셋 패널 안에 배치하여 그룹화 + +import { Box } from '@mui/material' +import { d3roPalette, d3roShadow, d3roRadius } from '../../theme' + +interface ButtonGroupProps { + children: React.ReactNode + /** 가로 배치 (기본 세로) */ + horizontal?: boolean +} + +export function ButtonGroup({ children, horizontal = false }: ButtonGroupProps): React.ReactElement { + return ( + + {children} + + ) +} diff --git a/src/renderer/components/ds/CrtDisplay.tsx b/src/renderer/components/ds/CrtDisplay.tsx index 80000f2..626b446 100644 --- a/src/renderer/components/ds/CrtDisplay.tsx +++ b/src/renderer/components/ds/CrtDisplay.tsx @@ -3,7 +3,8 @@ import { useRef, useEffect, useCallback } from 'react' import { Box } from '@mui/material' -import { d3roPalette, d3roFontMono } from '../../theme' +import { useTheme } from '@mui/material/styles' +import { d3roPalette, d3roFontMono, d3roShadow } from '../../theme' // ── WebGL 유틸 ───────────────────────────────────────── @@ -112,6 +113,8 @@ interface CrtDisplayProps { frequency?: number /** 글리치 트리거 (변경 시 글리치 발생) */ glitchTrigger?: number + /** 실시간 오디오 레벨 (0.0~1.0) — 파형 진폭에 반영 */ + audioLevel?: number /** 오버레이 콘텐츠 (인광 텍스트 등) */ children?: React.ReactNode /** 높이 (기본 280px) */ @@ -122,9 +125,12 @@ export function CrtDisplay({ amplitude = 0.1, frequency = 8.0, glitchTrigger = 0, + audioLevel = 0, children, height = 280, }: CrtDisplayProps): React.ReactElement { + const theme = useTheme() + const isLight = theme.palette.mode === 'light' const canvasRef = useRef(null) const glRef = useRef<{ gl: WebGLRenderingContext @@ -138,15 +144,20 @@ export function CrtDisplay({ const glitchRef = useRef(0) const ampRef = useRef(amplitude) const freqRef = useRef(frequency) + const audioLevelRef = useRef(audioLevel) const currentAmpRef = useRef(amplitude) const currentFreqRef = useRef(frequency) - // amplitude/frequency 변경 추적 + // amplitude/frequency/audioLevel 변경 추적 useEffect(() => { ampRef.current = amplitude freqRef.current = frequency }, [amplitude, frequency]) + useEffect(() => { + audioLevelRef.current = audioLevel + }, [audioLevel]) + // 글리치 트리거 useEffect(() => { if (glitchTrigger > 0) { @@ -160,8 +171,11 @@ export function CrtDisplay({ const { gl, uTime, uGlitch, uAmp, uFreq } = ctx + // audioLevel → amplitude 반영: 기본 amplitude + 오디오 레벨로 증폭 + const targetAmp = ampRef.current + audioLevelRef.current * 0.35 + // Smoothing - currentAmpRef.current += (ampRef.current - currentAmpRef.current) * 0.1 + currentAmpRef.current += (targetAmp - currentAmpRef.current) * 0.15 currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1 glitchRef.current *= 0.85 @@ -221,7 +235,7 @@ export function CrtDisplay({ height, bgcolor: d3roPalette.bg.crtBezel, borderRadius: '8px', - boxShadow: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #000, 0 1px 1px rgba(255,255,255,0.1)', + boxShadow: d3roShadow.insetDeep, overflow: 'hidden', }} > @@ -233,12 +247,19 @@ export function CrtDisplay({ borderRadius: '6px', bgcolor: d3roPalette.bg.crtGlass, overflow: 'hidden', - boxShadow: 'inset 0 0 20px rgba(0,0,0,0.8)', + boxShadow: d3roShadow.screenGlow, }} > {/* Glass reflection */} @@ -246,7 +267,9 @@ export function CrtDisplay({ sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: '50%', - background: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)', + background: isLight + ? 'linear-gradient(180deg, rgba(255,255,255,0.30) 0%, rgba(255,255,255,0) 100%)' + : 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)', pointerEvents: 'none', zIndex: 10, }} diff --git a/src/renderer/components/ds/InstrumentPanel.tsx b/src/renderer/components/ds/InstrumentPanel.tsx index 5e379bb..a887e2b 100644 --- a/src/renderer/components/ds/InstrumentPanel.tsx +++ b/src/renderer/components/ds/InstrumentPanel.tsx @@ -2,7 +2,8 @@ // 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감 import { Box, Typography } from '@mui/material' -import { d3roPalette, d3roFontMono } from '../../theme' +import { useTheme } from '@mui/material/styles' +import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme' interface InstrumentPanelProps { children: React.ReactNode @@ -25,16 +26,15 @@ export function InstrumentPanel({ sx={{ position: 'relative', bgcolor: d3roPalette.bg.chassis, - borderRadius: '24px', + borderRadius: d3roRadius.outer, p: 3, - boxShadow: - `0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 ${d3roPalette.led.off}, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)`, + boxShadow: d3roShadow.chassis, // 메탈 노이즈는 CSS로 시뮬레이션 '&::before': { content: '""', position: 'absolute', inset: 0, - borderRadius: '24px', + borderRadius: d3roRadius.outer, backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`, opacity: 0.04, mixBlendMode: 'overlay', @@ -57,14 +57,18 @@ export function InstrumentPanel({ } function Engraving({ children, sx }: { children: string; sx: Record }): React.ReactElement { + const theme = useTheme() + const isLight = theme.palette.mode === 'light' return ( - {/* 다이얼 외곽 */} + + {/* 다이얼 웰 (inset well) */} - {/* 동심원 그루브 (CSS로 시뮬레이션) */} + {/* 노브 회전체 (동심원 그루브) */} - {/* 포인터 인디케이터 */} + {/* 포인터 인디케이터 점 */} - {/* LED 인디케이터 (우측 상단) */} + {/* 정적 금속 광택 오버레이 (노브와 별개, 회전 안 함) */} {/* 라벨 */} {label && ( - + {label} - + )} ) diff --git a/src/renderer/components/ds/PhosphorText.tsx b/src/renderer/components/ds/PhosphorText.tsx index 2f12109..42f5591 100644 --- a/src/renderer/components/ds/PhosphorText.tsx +++ b/src/renderer/components/ds/PhosphorText.tsx @@ -1,16 +1,44 @@ // src/renderer/components/ds/PhosphorText.tsx // 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌 +// 확장: title/stat/body/compact/meta/engrave/micro/nano 변형 추가 import { Typography, type TypographyProps } from '@mui/material' -import { d3roPalette, d3roFontMono } from '../../theme' +import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' -type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim' +type PhosphorVariant = + | 'hero' | 'title' | 'value' | 'heading' + | 'body' | 'compact' | 'small' + | 'meta' | 'label' | 'dim' + | 'engrave' | 'micro' | 'nano' -const VARIANTS: Record = { - hero: { fontSize: '42px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 }, - value: { fontSize: '20px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 }, - label: { fontSize: '10px', color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: 700 }, - dim: { fontSize: '10px', color: d3roPalette.text.inactive, glow: 'none', fontWeight: 400 }, +interface VariantDef { + fontSize: string + color: string + glow: string + fontWeight: number + letterSpacing: string + lineHeight: number + textTransform?: 'uppercase' | 'none' +} + +const amberGlowStrong = `0 0 8px ${d3roPalette.accent.amberGlow}` +const amberGlowMedium = `0 0 6px rgba(242, 91, 41, 0.4)` +const amberGlowSoft = `0 0 4px rgba(242, 91, 41, 0.3)` + +const VARIANTS: Record = { + hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.accent.amber, glow: amberGlowStrong, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line }, + title: { fontSize: d3roTypo.title.size, color: d3roPalette.accent.amber, glow: amberGlowMedium, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line }, + value: { fontSize: d3roTypo.value.size, color: d3roPalette.accent.amber, glow: amberGlowSoft, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line }, + heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line }, + body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line }, + compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line }, + small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line }, + meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase' }, + label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' }, + dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line }, + engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.engraving, glow: 'none', fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase' }, + micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase' }, + nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase' }, } interface PhosphorTextProps extends Omit { @@ -28,11 +56,11 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr fontSize: v.fontSize, fontWeight: v.fontWeight, color: v.color, - textShadow: v.glow !== 'none' ? `0 0 6px ${v.glow}` : 'none', - letterSpacing: variant === 'label' ? '2px' : variant === 'hero' ? '-2px' : '0.02em', - lineHeight: 1, + textShadow: v.glow !== 'none' ? v.glow : 'none', + letterSpacing: v.letterSpacing, + lineHeight: v.lineHeight, fontVariantNumeric: 'tabular-nums', - textTransform: variant === 'label' ? 'uppercase' : 'none', + textTransform: v.textTransform ?? 'none', ...sx, }} /> diff --git a/src/renderer/components/ds/PhysicalButton.tsx b/src/renderer/components/ds/PhysicalButton.tsx index c23738b..6f87a13 100644 --- a/src/renderer/components/ds/PhysicalButton.tsx +++ b/src/renderer/components/ds/PhysicalButton.tsx @@ -1,8 +1,9 @@ // src/renderer/components/ds/PhysicalButton.tsx // 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태 +// 토큰 적용: d3roShadow, d3roRadius, d3roTypo import { Button, type ButtonProps } from '@mui/material' -import { d3roPalette, d3roFontMono } from '../../theme' +import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme' interface PhysicalButtonProps extends Omit { selected?: boolean @@ -16,26 +17,24 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto height: 44, bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis, border: 'none', - borderRadius: '6px', + borderRadius: d3roRadius.small, color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive, fontFamily: d3roFontMono, - fontSize: '12px', - fontWeight: 600, + fontSize: d3roTypo.small.size, + fontWeight: d3roTypo.small.weight, cursor: 'pointer', - boxShadow: selected - ? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000' - : '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.2)', + boxShadow: selected ? d3roShadow.buttonPressed : d3roShadow.buttonRaised, transform: selected ? 'translateY(1px)' : 'none', transition: 'all 0.05s linear', '&:active': { transform: 'translateY(2px)', - boxShadow: '0 1px 2px rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)', + boxShadow: d3roShadow.buttonActive, }, '&:hover': { bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover, }, textTransform: 'uppercase', - letterSpacing: '0.5px', + letterSpacing: d3roTypo.label.spacing, minWidth: 0, ...sx, }} diff --git a/src/renderer/components/ds/ScreenPanel.tsx b/src/renderer/components/ds/ScreenPanel.tsx new file mode 100644 index 0000000..2822b3e --- /dev/null +++ b/src/renderer/components/ds/ScreenPanel.tsx @@ -0,0 +1,69 @@ +// src/renderer/components/ds/ScreenPanel.tsx +// 시안 A: CRT 없는 순수 스크린 패널 — 인셋 베젤 + 글래스 반사 + 인광 텍스트용 +// 레퍼런스의 .display-module > .screen-glass 패턴 + +import { Box } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { d3roPalette, d3roShadow } from '../../theme' + +interface ScreenPanelProps { + children: React.ReactNode + /** 전체 높이 (px 또는 CSS 값) */ + height?: number | string +} + +export function ScreenPanel({ children, height }: ScreenPanelProps): React.ReactElement { + const theme = useTheme() + const isLight = theme.palette.mode === 'light' + return ( + + {/* 글래스 배경 */} + + + {/* 콘텐츠 오버레이 */} + + {children} + + + ) +} diff --git a/src/renderer/components/ds/index.ts b/src/renderer/components/ds/index.ts index 253f650..2231deb 100644 --- a/src/renderer/components/ds/index.ts +++ b/src/renderer/components/ds/index.ts @@ -8,3 +8,5 @@ export { PhysicalButton } from './PhysicalButton' export { MetalCard } from './MetalCard' export { PhosphorText } from './PhosphorText' export { MetalDial } from './MetalDial' +export { ScreenPanel } from './ScreenPanel' +export { ButtonGroup } from './ButtonGroup' diff --git a/src/renderer/components/shared/EmptyStateCard.tsx b/src/renderer/components/shared/EmptyStateCard.tsx new file mode 100644 index 0000000..9c19afc --- /dev/null +++ b/src/renderer/components/shared/EmptyStateCard.tsx @@ -0,0 +1,19 @@ +// src/renderer/components/shared/EmptyStateCard.tsx +// 공유: 데이터 없음 상태 카드 + +import { Box } from '@mui/material' +import { MetalCard, PhosphorText } from '../ds' + +interface EmptyStateCardProps { + message: string +} + +export function EmptyStateCard({ message }: EmptyStateCardProps): React.ReactElement { + return ( + + + {message} + + + ) +} diff --git a/src/renderer/components/shared/HistoryEntryCard.tsx b/src/renderer/components/shared/HistoryEntryCard.tsx new file mode 100644 index 0000000..fc97c7f --- /dev/null +++ b/src/renderer/components/shared/HistoryEntryCard.tsx @@ -0,0 +1,185 @@ +// src/renderer/components/shared/HistoryEntryCard.tsx +// 공유: 히스토리 항목 카드 (Dashboard + HistoryPage에서 재사용) +// Phase 10: 태그 표시/추가/삭제 기능 통합 + +import { useState, useEffect, useCallback } from 'react' +import { Box, IconButton, Tooltip, Chip } from '@mui/material' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import DeleteIcon from '@mui/icons-material/Delete' +import LocalOfferIcon from '@mui/icons-material/LocalOffer' +import CloseIcon from '@mui/icons-material/Close' +import { MetalCard, Led } from '../ds' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme' +import { useI18n } from '../../i18n' +import { formatDuration } from '../../utils/formatters' +import type { HistoryEntry, MemoTag } from '@shared/types' + +interface HistoryEntryCardProps { + entry: HistoryEntry + onCopy?: (text: string) => void + onDelete?: (id: string) => void + showTags?: boolean + onTagClick?: (tag: string) => void +} + +export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, onTagClick }: HistoryEntryCardProps): React.ReactElement { + const { t, formatTime } = useI18n() + const displayText = entry.polishedText || entry.originalText + const [tags, setTags] = useState([]) + const [tagInput, setTagInput] = useState('') + const [showTagInput, setShowTagInput] = useState(false) + + const loadTags = useCallback(async () => { + if (!showTags) return + const result = await window.electronAPI.memo.getTags(entry.id) + if (result.success) setTags(result.data) + }, [entry.id, showTags]) + + useEffect(() => { loadTags() }, [loadTags]) + + const handleAddTag = useCallback(async () => { + const trimmed = tagInput.trim() + if (!trimmed) return + const result = await window.electronAPI.memo.addTag(entry.id, trimmed) + if (result.success) { + setTags(prev => [...prev, result.data]) + setTagInput('') + setShowTagInput(false) + } + }, [entry.id, tagInput]) + + const handleRemoveTag = useCallback(async (tag: string) => { + const result = await window.electronAPI.memo.removeTag(entry.id, tag) + if (result.success) { + setTags(prev => prev.filter(t => t.tag !== tag)) + } + }, [entry.id]) + + const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } + if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') } + }, [handleAddTag]) + + return ( + + + + + + {displayText} + + + {formatTime(entry.createdAt)} + {formatDuration(entry.duration)} + {entry.detectedLanguage && {entry.detectedLanguage.toUpperCase()}} + {entry.mode.toUpperCase()} + + {/* 태그 영역 */} + {showTags && ( + + {tags.map(tag => ( + onTagClick?.(tag.tag)} + onDelete={() => handleRemoveTag(tag.tag)} + deleteIcon={} + sx={{ + height: 20, + fontFamily: d3roFontMono, + fontSize: d3roTypo.micro.size, + bgcolor: d3roPalette.tag.purpleBg, + color: d3roPalette.tag.purple, + borderRadius: d3roRadius.small, + '& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 12 }, + '&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.bg.card }, + }} + /> + ))} + {showTagInput ? ( + ) => setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + onBlur={() => { if (!tagInput.trim()) setShowTagInput(false) }} + autoFocus + placeholder={t('memo.tagPlaceholder')} + sx={{ + border: `1px solid ${d3roPalette.border.subtle}`, + bgcolor: d3roPalette.bg.input, + color: d3roPalette.text.primary, + fontFamily: d3roFontMono, + fontSize: d3roTypo.micro.size, + px: 1, + py: 0.25, + borderRadius: d3roRadius.xs, + outline: 'none', + width: 100, + '&:focus': { borderColor: d3roPalette.accent.amber }, + }} + /> + ) : ( + + setShowTagInput(true)} + sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }} + > + + + + )} + + )} + + + {onCopy && ( + + onCopy(displayText)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} + > + + + + )} + {onDelete && ( + + onDelete(entry.id)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }} + > + + + + )} + + + + ) +} diff --git a/src/renderer/components/shared/PageHeader.tsx b/src/renderer/components/shared/PageHeader.tsx new file mode 100644 index 0000000..f421c23 --- /dev/null +++ b/src/renderer/components/shared/PageHeader.tsx @@ -0,0 +1,30 @@ +// src/renderer/components/shared/PageHeader.tsx +// 공유: 각인 스타일 페이지 헤더 (타이틀 + 카운트 + 옵션 액션) + +import { Box } from '@mui/material' +import { PhosphorText } from '../ds' +import { d3roPalette } from '../../theme' + +interface PageHeaderProps { + title: string + count?: string + action?: React.ReactNode +} + +export function PageHeader({ title, count, action }: PageHeaderProps): React.ReactElement { + return ( + + + + {title} + + {count && ( + + {count} + + )} + + {action} + + ) +} diff --git a/src/renderer/components/shared/SearchInput.tsx b/src/renderer/components/shared/SearchInput.tsx new file mode 100644 index 0000000..31218d7 --- /dev/null +++ b/src/renderer/components/shared/SearchInput.tsx @@ -0,0 +1,40 @@ +// src/renderer/components/shared/SearchInput.tsx +// 공유: 모노 폰트 검색 입력 필드 + +import { TextField, InputAdornment } from '@mui/material' +import SearchIcon from '@mui/icons-material/Search' +import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' + +interface SearchInputProps { + value: string + onChange: (value: string) => void + placeholder: string +} + +export function SearchInput({ value, onChange, placeholder }: SearchInputProps): React.ReactElement { + return ( + onChange(e.target.value)} + fullWidth + sx={{ + mb: 3, + '& .MuiInputBase-input': { + fontFamily: d3roFontMono, + fontSize: d3roTypo.small.size, + letterSpacing: d3roTypo.small.spacing, + }, + }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + ) +} diff --git a/src/renderer/components/shared/index.ts b/src/renderer/components/shared/index.ts new file mode 100644 index 0000000..c778e37 --- /dev/null +++ b/src/renderer/components/shared/index.ts @@ -0,0 +1,7 @@ +// src/renderer/components/shared/index.ts +// 공유 컴포넌트 barrel export + +export { EmptyStateCard } from './EmptyStateCard' +export { SearchInput } from './SearchInput' +export { PageHeader } from './PageHeader' +export { HistoryEntryCard } from './HistoryEntryCard' diff --git a/src/renderer/hooks/useProFeature.ts b/src/renderer/hooks/useProFeature.ts new file mode 100644 index 0000000..5b3424c --- /dev/null +++ b/src/renderer/hooks/useProFeature.ts @@ -0,0 +1,54 @@ +// src/renderer/hooks/useProFeature.ts +// Pro feature gating hook: checks access, subscribes to tier changes + +import { useState, useEffect, useCallback } from 'react' +import { Feature } from '@shared/types' +import type { FeatureAccess } from '@shared/types' + +interface UseProFeatureResult { + /** Feature is unlocked for current tier */ + unlocked: boolean + /** Loading initial access check */ + loading: boolean + /** Full access info (null while loading) */ + access: FeatureAccess | null + /** Opens the license modal (dispatches custom event) */ + showUpgrade: () => void +} + +export function useProFeature(feature: Feature): UseProFeatureResult { + const [access, setAccess] = useState(null) + const [loading, setLoading] = useState(true) + + const checkAccess = useCallback(() => { + window.electronAPI.license.checkFeature(feature).then((result) => { + if (result.success) { + setAccess(result.data) + } + setLoading(false) + }) + }, [feature]) + + useEffect(() => { + checkAccess() + + // Re-check when tier changes + const unsub = window.electronAPI.license.onTierChanged(() => { + checkAccess() + }) + + return unsub + }, [checkAccess]) + + const showUpgrade = useCallback(() => { + // Dispatch custom event that LicenseModal listens to + window.dispatchEvent(new CustomEvent('d3ro:open-license-modal')) + }, []) + + return { + unlocked: access?.allowed ?? false, + loading, + access, + showUpgrade, + } +} diff --git a/src/renderer/i18n/de.json b/src/renderer/i18n/de.json new file mode 100644 index 0000000..33be0b9 --- /dev/null +++ b/src/renderer/i18n/de.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "Lokaler KI-Sprachassistent", + + "nav.dashboard": "Dashboard", + "nav.history": "Verlauf", + "nav.dictionary": "Wörterbuch", + "nav.commands": "Befehle", + "nav.settings": "Einstellungen", + + "dashboard.sessionOverview": "Sitzungsübersicht", + "dashboard.systemStatus": "Systemstatus", + "dashboard.sessionsToday": "Sitzungen heute", + "dashboard.pressToRecord": "Drücke {{key}}, um die Aufnahme zu starten", + "dashboard.hotkeyNotSet": "Tastenkürzel nicht festgelegt", + "dashboard.words": "Wörter", + "dashboard.total": "gesamt", + "dashboard.streak": "Serie", + "dashboard.days": "Tage", + "dashboard.recording": "Aufnahme", + "dashboard.sessions": "Sitzungen", + "dashboard.today": "heute", + "dashboard.recentTranscriptions": "Letzte Transkriptionen", + "dashboard.noHistory": "Kein Verlauf — drücke das Tastenkürzel, um die Aufnahme zu starten", + "dashboard.copy": "Kopieren", + "dashboard.entries": "{{count}} Einträge", + "dashboard.stat": "Statistiken", + "dashboard.sys": "System", + + "history.title": "Transkriptionsverlauf", + "history.search": "Suchen...", + "history.entries": "{{count}} Einträge", + "history.loading": "Laden...", + "history.noResults": "Keine Ergebnisse", + "history.noHistory": "Kein Verlauf — starte eine Aufnahme", + "history.count": "{{count}} Einträge", + + "dictionary.title": "Benutzerwörterbuch", + "dictionary.words": "{{count}} Wörter", + "dictionary.add": "Hinzufügen", + "dictionary.search": "Suchen...", + "dictionary.loading": "Laden...", + "dictionary.noResults": "Keine Ergebnisse", + "dictionary.noWords": "Keine Wörter — füge benutzerdefinierte Wörter hinzu, um die STT-Genauigkeit zu verbessern", + "dictionary.used": "{{count}} Mal verwendet", + "dictionary.editTitle": "Wort bearbeiten", + "dictionary.addTitle": "Wort hinzufügen", + "dictionary.word": "Wort", + "dictionary.pronunciation": "Aussprache (optional)", + + "commands.title": "LLM-Befehle", + "commands.count": "{{count}} Befehle", + "commands.add": "Hinzufügen", + "commands.activeCommand": "Aktiver Befehl", + "commands.none": "Keiner", + "commands.loading": "Laden...", + "commands.noCommands": "Keine Befehle — klicke auf Hinzufügen, um einen zu erstellen", + "commands.editTitle": "Befehl bearbeiten", + "commands.addTitle": "Befehl hinzufügen", + "commands.name": "Name", + "commands.description": "Beschreibung", + "commands.promptTemplate": "Prompt-Vorlage", + "commands.promptHelp": "{{text}} wird durch den transkribierten Text ersetzt", + "commands.defaultPrompt": "Bitte verbessere {{text}}.", + + "settings.title": "Einstellungen", + "settings.tabs.general": "Allgemein", + "settings.tabs.audio": "Audio", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "Über", + + "settings.shortcuts": "Tastenkürzel", + "settings.dictation": "Diktat", + "settings.dictation.desc": "Gedrückt halten zum Sprechen. Die Transkription beginnt beim Loslassen.", + "settings.agent": "Agentenmodus", + "settings.agent.descWithKey": "Doppelklick auf {{key}}, um den Agentenmodus aufzurufen.", + "settings.agent.descNoKey": "Lege zuerst das Diktat-Tastenkürzel fest.", + "settings.oneTouch": "Ein-Tasten-Modus", + "settings.oneTouch.desc": "Drücken zum Starten, erneut drücken zum Beenden. Erfordert ein eigenes Tastenkürzel.", + "settings.key": "Taste", + "settings.notSet": "Nicht festgelegt", + "settings.enabled": "Aktiviert", + "settings.disabled": "Deaktiviert", + + "settings.interface": "Oberfläche", + "settings.theme": "Design", + "settings.theme.system": "System", + "settings.theme.light": "Hell", + "settings.theme.dark": "Dunkel", + "settings.language": "Sprache", + + "settings.appBehavior": "App-Verhalten", + "settings.closeToTray": "In Systembereich minimieren", + "settings.autoLaunch": "Beim Systemstart automatisch starten", + "settings.autoInsert": "Text nach der Transkription automatisch einfügen", + "settings.soundEffects": "Soundeffekte", + + "settings.microphone": "Mikrofon", + "settings.inputDevice": "Eingabegerät", + "settings.deviceDefault": "(Standard)", + "settings.textInsert": "Texteinfügung", + "settings.insertMethod": "Einfügemethode", + "settings.insertClipboard": "Zwischenablage (Ctrl+V)", + "settings.insertKeyboard": "Tastatureingabe", + + "settings.whisperModel": "Whisper-Modell", + "settings.model.tiny": "tiny (39 MB, am schnellsten)", + "settings.model.base": "base (74 MB, ausgewogen)", + "settings.model.small": "small (244 MB, gut)", + "settings.model.medium": "medium (769 MB, sehr gut)", + "settings.model.large": "large-v3 (1,5 GB, beste Qualität)", + "settings.sttLanguage": "Erkennungssprache", + "settings.sttLang.auto": "Automatische Erkennung", + + "settings.ollamaServer": "Ollama-Server", + "settings.ollamaUrl": "Ollama-Server-URL", + "settings.ollamaHint": "D3RO Voice verbindet sich automatisch, wenn Ollama läuft. Lade Modelle direkt über Ollama herunter (z. B.: ollama pull qwen3:4b).", + "settings.postProcess": "Sprachnachbearbeitung", + "settings.defaultAction": "Standard-Nachbearbeitungsaktion", + "settings.action.none": "Keine (Originaltext unverändert)", + "settings.action.refine": "Verfeinern (Grammatik + Natürlichkeit)", + "settings.action.translate": "Übersetzen", + "settings.action.summarize": "Zusammenfassen", + "settings.action.grammar": "Grammatikkorrektur", + "settings.action.custom": "Benutzerdefinierter Prompt", + "settings.actionHint": "Die gewählte LLM-Nachbearbeitung wird auf den transkribierten Text angewendet, nachdem mit dem Tastenkürzel aufgenommen wurde. Funktioniert nur, wenn Ollama verbunden ist.", + + "settings.about.version": "Version", + "settings.about.techStack": "Tech-Stack", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Sprach-Engine", + "settings.about.voiceEngineValue": "STT: faster-whisper (lokal) / LLM: Ollama (lokal)", + "settings.about.description": "Vollständig lokaler KI-Sprachassistent, basierend auf Reverse Engineering von Speakly. Funktioniert ohne Cloud-Abhängigkeiten.", + "settings.about.restartOnboarding": "Einrichtungsanleitung erneut anzeigen", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama läuft nicht", + "status.nudge.desc": "Ollama wird für die LLM-Nachbearbeitung (Übersetzung, Zusammenfassung usw.) benötigt.", + "status.nudge.guide": "Installationsanleitung anzeigen →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "Ohne Tippen, per Sprache. Dein lokaler KI-Sprachassistent.", + "onboarding.welcome.start": "Loslegen", + "onboarding.mic.title": "Mikrofon einrichten", + "onboarding.mic.desc": "Wähle das Mikrofon aus, das du verwenden möchtest. Du kannst es später in den Einstellungen ändern.", + "onboarding.hotkey.title": "Tastenkürzel einrichten", + "onboarding.hotkey.desc": "Lege das Diktat-Tastenkürzel fest. Die Aufnahme läuft, solange du die Taste gedrückt hältst.", + "onboarding.hotkey.notSet": "Kein Tastenkürzel festgelegt", + "onboarding.hotkey.change": "Tastenkürzel ändern", + "onboarding.hotkey.set": "Tastenkürzel festlegen", + "onboarding.ollama.title": "Ollama installieren (optional)", + "onboarding.ollama.desc": "Ollama wird für die LLM-Nachbearbeitung wie Übersetzung oder Zusammenfassung benötigt. Die Sprachdiktierung funktioniert ohne Ollama.", + "onboarding.ollama.download": "Ollama herunterladen", + "onboarding.ollama.modelHint": "Lade nach der Installation ein Modell über das Terminal herunter", + "onboarding.done.title": "Einrichtung abgeschlossen!", + "onboarding.done.descWithKey": "Halte {{key}} gedrückt und sprich, um deine Stimme in Text umzuwandeln.", + "onboarding.done.descNoKey": "Lege in den Einstellungen ein Tastenkürzel fest, um mit der Spracheingabe zu beginnen.", + "onboarding.done.start": "Loslegen", + "onboarding.back": "Zurück", + "onboarding.next": "Weiter", + + "hotkey.title": "Tastenkürzel einrichten", + "hotkey.dictationTitle": "Diktat-Tastenkürzel einrichten", + "hotkey.oneTouchTitle": "Tastenkürzel für Ein-Tasten-Modus einrichten", + "hotkey.prompt": "Drücke eine Tastenkombination...", + "hotkey.ready": "✓ {{keys}} — drücke Speichern", + "hotkey.noKey": "Bitte gib eine Taste ein", + "hotkey.reserved": "{{keys}} ist ein vom System reserviertes Tastenkürzel", + "hotkey.current": "Aktuell: {{keys}}", + "hotkey.hint": "Gib eine Kombination (z. B. Ctrl+Shift+Q) oder eine einzelne Taste (z. B. F5) ein", + "hotkey.reset": "Erneut eingeben", + + "ollama.title": "OLLAMA EINRICHTUNGSANLEITUNG", + "ollama.step1.title": "SCHRITT 1 — Ollama installieren", + "ollama.step1.desc": "Ollama ist ein kostenloses Tool zum lokalen Ausführen von LLMs.", + "ollama.step2.title": "SCHRITT 2 — Modell herunterladen", + "ollama.step2.desc": "Führe den folgenden Befehl im Terminal aus, um das empfohlene Modell herunterzuladen:", + "ollama.step2.alt": "Oder ein größeres Modell: ollama pull qwen3:8b (genauer, langsamer)", + "ollama.step3.title": "SCHRITT 3 — Automatische Verbindung", + "ollama.step3.desc": "D3RO-VOICE erkennt automatisch, wenn Ollama läuft. Wenn die LED in der Statusleiste von Rot auf Grün wechselt, ist alles bereit!", + + "service.sttEngine": "STT-ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "TASTENKÜRZEL", + "service.audioInput": "AUDIOEINGABE", + "service.ready": "BEREIT", + "service.connected": "VERBUNDEN", + "service.offline": "OFFLINE", + "service.active": "AKTIV", + "service.standby": "BEREITSCHAFT", + + "common.cancel": "Abbrechen", + "common.save": "Speichern", + "common.delete": "Löschen", + "common.add": "Hinzufügen", + "common.edit": "Bearbeiten", + "common.close": "Schließen", + "common.confirm": "Bestätigen", + "common.loading": "Laden...", + "common.copy": "Kopieren", + "common.test": "Testen", + "common.stop": "Stoppen", + + "date.today": "Heute", + "date.yesterday": "Gestern" +} diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json index 38ff73a..d3a734b 100644 --- a/src/renderer/i18n/en.json +++ b/src/renderer/i18n/en.json @@ -1,58 +1,331 @@ { "app.name": "D3RO Voice", + "app.tagline": "Local AI Voice Assistant", + "nav.dashboard": "Dashboard", "nav.history": "History", "nav.dictionary": "Dictionary", "nav.commands": "Commands", "nav.settings": "Settings", - "dashboard.title": "Dashboard", - "dashboard.totalSessions": "Total Sessions", - "dashboard.totalTime": "Total Time", - "dashboard.totalWords": "Total Words", - "dashboard.streak": "Streak", - "dashboard.today": "Today", - "dashboard.sessions": "Sessions", - "dashboard.time": "Time", + + "dashboard.sessionOverview": "Session Overview", + "dashboard.systemStatus": "System Status", + "dashboard.sessionsToday": "Today's Sessions", + "dashboard.pressToRecord": "Press {{key}} to start recording", + "dashboard.hotkeyNotSet": "Hotkey not set", "dashboard.words": "Words", - "history.title": "History", - "history.search": "Search transcriptions...", - "history.empty": "No history yet.", - "history.noResults": "No results found.", - "dictionary.title": "Dictionary", - "dictionary.search": "Search words...", - "dictionary.addWord": "Add Word", - "dictionary.empty": "No words yet. Add custom words for better STT accuracy.", - "dictionary.noResults": "No words found.", + "dashboard.total": "Total", + "dashboard.streak": "Streak", + "dashboard.days": "days", + "dashboard.recording": "Recording", + "dashboard.sessions": "Sessions", + "dashboard.today": "Today", + "dashboard.recentTranscriptions": "Recent Transcriptions", + "dashboard.noHistory": "No history — press your hotkey to start recording", + "dashboard.copy": "Copy", + "dashboard.entries": "{{count}} entries", + "dashboard.stat": "Stats", + "dashboard.sys": "System", + + "history.title": "Transcription History", + "history.search": "Search...", + "history.entries": "{{count}} entries", + "history.loading": "Loading...", + "history.noResults": "No results found", + "history.noHistory": "No history — start recording", + "history.count": "{{count}} entries", + + "dictionary.title": "Custom Dictionary", + "dictionary.words": "{{count}} words", + "dictionary.add": "Add", + "dictionary.search": "Search...", + "dictionary.loading": "Loading...", + "dictionary.noResults": "No results found", + "dictionary.noWords": "No words — add custom words to improve STT accuracy", + "dictionary.used": "Used {{count}} times", + "dictionary.editTitle": "Edit Word", + "dictionary.addTitle": "Add Word", "dictionary.word": "Word", "dictionary.pronunciation": "Pronunciation (optional)", - "commands.title": "Custom Commands", - "commands.add": "Add Command", + + "commands.title": "LLM Commands", + "commands.count": "{{count}} commands", + "commands.add": "Add", + "commands.activeCommand": "Active Command", + "commands.none": "None", + "commands.loading": "Loading...", + "commands.noCommands": "No commands — click Add to create one", + "commands.editTitle": "Edit Command", + "commands.addTitle": "Add Command", "commands.name": "Name", "commands.description": "Description", - "commands.prompt": "Prompt", - "commands.builtin": "Built-in", - "commands.custom": "Custom", + "commands.promptTemplate": "Prompt Template", + "commands.promptHelp": "{{text}} will be replaced with the transcribed text", + "commands.defaultPrompt": "Please refine the following: {{text}}", + "settings.title": "Settings", - "settings.general": "General", - "settings.audio": "Audio", - "settings.stt": "STT", - "settings.llm": "LLM", + "settings.tabs.general": "General", + "settings.tabs.audio": "Audio", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "About", + + "settings.shortcuts": "Shortcuts", + "settings.dictation": "Dictation", + "settings.dictation.desc": "Hold to speak. Transcription begins when you release the key.", + "settings.agent": "Agent Mode", + "settings.agent.descWithKey": "Double-press {{key}} to enter Agent mode.", + "settings.agent.descNoKey": "Set a dictation hotkey first.", + "settings.oneTouch": "One-Touch Mode", + "settings.oneTouch.desc": "Press to start, press again to stop. Requires a separate shortcut.", + "settings.caption": "Live Caption", + "settings.caption.desc": "Toggle live caption mode with hotkey. Transcribes microphone input in real-time.", + "settings.captionAudio": "Caption Audio Source", + "settings.captionSource": "Audio Source", + "settings.captionSource.mic": "Microphone only", + "settings.captionSource.system": "System audio only (desktop sound)", + "settings.captionSource.both": "Microphone + System audio", + "settings.key": "Key", + "settings.notSet": "Not set", + "settings.enabled": "Enabled", + "settings.disabled": "Disabled", + + "settings.interface": "Interface", "settings.theme": "Theme", + "settings.theme.system": "System", + "settings.theme.light": "Light", + "settings.theme.dark": "Dark", "settings.language": "Language", - "settings.closeToTray": "Close to tray", + + "settings.appBehavior": "App Behavior", + "settings.closeToTray": "Minimize to tray on close", + "settings.autoLaunch": "Launch at system startup", "settings.autoInsert": "Auto-insert text after transcription", - "settings.soundEffects": "Sound effects", + "settings.soundEffects": "Sound Effects", + + "settings.microphone": "Microphone", + "settings.inputDevice": "Input Device", + "settings.deviceDefault": "(Default)", + "settings.textInsert": "Text Insertion", "settings.insertMethod": "Insert Method", + "settings.insertClipboard": "Clipboard (Ctrl+V)", + "settings.insertKeyboard": "Keyboard Typing", + "settings.whisperModel": "Whisper Model", + "settings.model.tiny": "tiny (39 MB, fastest)", + "settings.model.base": "base (74 MB, balanced)", + "settings.model.small": "small (244 MB, good)", + "settings.model.medium": "medium (769 MB, great)", + "settings.model.large": "large-v3 (1.5 GB, best)", "settings.sttLanguage": "Recognition Language", + "settings.sttLang.auto": "Auto-detect", + + "settings.ollamaServer": "Ollama Server", "settings.ollamaUrl": "Ollama Server URL", + "settings.ollamaHint": "Connects automatically when Ollama is running. Pull models directly in Ollama (e.g. ollama pull qwen3:4b).", + "settings.postProcess": "Voice Post-Processing", + "settings.defaultAction": "Default Post-Processing Command", + "settings.action.none": "None (raw transcription)", + "settings.action.refine": "Refine (grammar + fluency)", + "settings.action.translate": "Translate", + "settings.action.summarize": "Summarize", + "settings.action.grammar": "Grammar Correction", + "settings.action.custom": "Custom Prompt", + "settings.actionHint": "The selected LLM post-processing will be applied to your transcription after recording. Only works when Ollama is connected.", + + "settings.about.version": "Version", + "settings.about.techStack": "Tech Stack", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Voice Engine", + "settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)", + "settings.about.description": "A fully local AI voice assistant built on Speakly reverse-engineering insights. Runs entirely offline with no cloud dependencies.", + "settings.about.restartOnboarding": "Restart Setup Wizard", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama is not running", + "status.nudge.desc": "Ollama is required for LLM post-processing (translation, summarization, etc.).", + "status.nudge.guide": "View setup guide →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "No typing needed — just speak. Your local AI voice assistant.", + "onboarding.welcome.start": "Get Started", + "onboarding.mic.title": "Microphone Setup", + "onboarding.mic.desc": "Choose a microphone to use. You can change this later in Settings.", + "onboarding.hotkey.title": "Shortcut Setup", + "onboarding.hotkey.desc": "Set your dictation shortcut. Hold the key while speaking to record.", + "onboarding.hotkey.notSet": "No shortcut set", + "onboarding.hotkey.change": "Change Shortcut", + "onboarding.hotkey.set": "Set Shortcut", + "onboarding.ollama.title": "Install Ollama (Optional)", + "onboarding.ollama.desc": "Ollama is required for LLM post-processing like translation and summarization. Voice dictation works without it.", + "onboarding.ollama.download": "Download Ollama", + "onboarding.ollama.modelHint": "After installation, download a model from your terminal", + "onboarding.done.title": "You're all set!", + "onboarding.done.descWithKey": "Hold {{key}} and speak — your voice will be converted to text.", + "onboarding.done.descNoKey": "Set a shortcut in Settings to start voice dictation.", + "onboarding.done.start": "Start Using", + "onboarding.back": "Back", + "onboarding.next": "Next", + + "hotkey.title": "Set Shortcut", + "hotkey.dictationTitle": "Set Dictation Shortcut", + "hotkey.oneTouchTitle": "Set One-Touch Mode Shortcut", + "hotkey.captionTitle": "Set Live Caption Shortcut", + "hotkey.prompt": "Press a key combination...", + "hotkey.ready": "✓ {{keys}} — press Save to confirm", + "hotkey.noKey": "Please press a key", + "hotkey.reserved": "{{keys}} is a reserved system shortcut", + "hotkey.current": "Current: {{keys}}", + "hotkey.hint": "Enter a combination (e.g. Ctrl+Shift+Q) or a single key (e.g. F5)", + "hotkey.reset": "Clear", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "STEP 1 — Install Ollama", + "ollama.step1.desc": "Ollama is a free tool for running LLMs locally.", + "ollama.step2.title": "STEP 2 — Download a Model", + "ollama.step2.desc": "Pull a model from your terminal. Recommended:", + "ollama.step2.alt": "Or a larger model: ollama pull qwen3:8b (more accurate, slower)", + "ollama.step3.title": "STEP 3 — Auto-Connect", + "ollama.step3.desc": "Once Ollama is running, D3RO-VOICE will detect it automatically. When the status bar LED turns from red to green, you're ready!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + "common.cancel": "Cancel", "common.save": "Save", "common.delete": "Delete", "common.add": "Add", "common.edit": "Edit", "common.close": "Close", + "common.confirm": "Confirm", "common.loading": "Loading...", - "status.ollamaConnected": "Ollama Connected", - "status.ollamaOffline": "Ollama Offline" + "common.copy": "Copy", + "common.test": "Test", + "common.stop": "Stop", + + "memo.tags": "Tags", + "memo.addTag": "Add Tag", + "memo.removeTag": "Remove Tag", + "memo.tagPlaceholder": "Type a tag and press Enter", + "memo.noTags": "No tags", + "memo.export": "Export as Markdown", + "memo.exportSuccess": "Export complete", + "memo.filterByTag": "Filter by tag", + "memo.allTags": "All Tags", + "memo.clearFilter": "Clear filter", + + "voiceCommand.title": "Voice Commands", + "voiceCommand.enabled": "Voice Keyword Recognition", + "voiceCommand.enabledDesc": "Detects keywords at the start of transcription to auto-select commands.", + "voiceCommand.keywords": "Keywords", + "voiceCommand.keywordPlaceholder": "Type keyword and press Enter", + "voiceCommand.noKeywords": "No keywords", + "voiceCommand.matchMode": "Match Mode", + "voiceCommand.matchMode.prefix": "Starts with", + "voiceCommand.matchMode.suffix": "Ends with", + "voiceCommand.matchMode.contains": "Contains", + + "chain.title": "LLM Chains", + "chain.count": "{{count}} chains", + "chain.add": "Add Chain", + "chain.editTitle": "Edit Chain", + "chain.addTitle": "Add Chain", + "chain.name": "Chain Name", + "chain.steps": "Steps", + "chain.addStep": "Add Step", + "chain.removeStep": "Remove", + "chain.inputSource": "Input Source", + "chain.inputSource.original": "Original Text", + "chain.inputSource.previous": "Previous Step Result", + "chain.selectInstruction": "Select Command", + "chain.execute": "Execute", + "chain.executing": "Executing...", + "chain.noChains": "No chains — create a pipeline to run multiple commands in sequence", + "chain.step": "Step {{n}}", + "chain.progress": "Processing step {{current}}/{{total}}", + + "context.title": "Screen Context", + "context.enabled": "Screen Context", + "context.enabledDesc": "Captures active app and selected text when recording starts, and sends it to the LLM.", + + "dashboard.caption": "Live Caption", + "dashboard.captionStart": "Start Caption", + "dashboard.captionStop": "Stop Caption", + "dashboard.captionActive": "Caption Active", + + "settings.voiceCommands": "Voice Commands", + "settings.voiceCommands.desc": "Detects keywords in transcription to auto-select commands.", + "settings.screenContext": "Screen Context", + "settings.screenContext.desc": "Sends active app info and selected text to the LLM during recording.", + + "date.today": "Today", + "date.yesterday": "Yesterday", + + "license.title": "License", + "license.currentTier": "Current Tier", + "license.free": "FREE", + "license.pro": "PRO", + "license.proPlus": "PRO+", + "license.activate": "Activate License", + "license.deactivate": "Deactivate", + "license.keyPlaceholder": "Enter license key...", + "license.activating": "Activating...", + "license.activated": "Activated", + "license.activateError": "Activation failed: {{message}}", + "license.deactivated": "Deactivated", + "license.machineId": "Machine ID", + "license.activatedAt": "Activated At", + "license.manageLicense": "Manage License", + "license.upgrade": "Upgrade", + "license.upgradeTitle": "Upgrade to Pro", + "license.upgradeDesc": "Unlock all features", + "license.quotaUsed": "{{used}}/{{limit}} used", + "license.quotaUnlimited": "Unlimited", + "license.tierComparison": "Tier Comparison", + "license.dailyUsage": "Daily Usage", + "license.feature.dictation": "Dictation", + "license.feature.llm_process": "LLM Processing", + "license.feature.live_caption": "Live Caption", + "license.feature.screen_context": "Screen Context", + "license.feature.voice_command": "Voice Commands", + "license.feature.llm_chain": "LLM Chain", + "license.feature.voice_memo": "Voice Memo", + "license.feature.history_unlimited": "Unlimited History", + "license.feature.history_export": "History Export", + "license.feature.custom_instruction_create": "Custom Instruction Create", + "license.feature.file_transcription": "File Transcription", + "license.feature.voice_conversation": "Voice Conversation", + "license.feature.dictation_template": "Dictation Template", + "license.feature.meeting_summary": "Meeting Summary", + "license.feature.local_rag": "Local RAG", + "license.feature.os_automation": "OS Automation", + "license.pro.required": "PRO Required", + "license.proPlus.required": "PRO+ Required", + "license.included": "Included", + "license.notIncluded": "Not included", + "license.keyLabel": "License Key", + "license.quotaExceeded.title": "You've used all your daily {{feature}}", + "license.quotaExceeded.desc": "Upgrade to Pro for unlimited usage", + "license.tierRequired.title": "{{feature}} is a {{tier}} feature", + "license.tierRequired.desc": "Upgrade to {{tier}} to unlock", + "license.tryTomorrow": "Try again tomorrow", + "license.learnMore": "Learn more", + "license.upgradeBenefits": "Upgrade Benefits", + "license.benefit.unlimitedDictation": "Unlimited dictation", + "license.benefit.unlimitedLLM": "Unlimited AI polish", + "license.benefit.liveCaption": "Live captions", + "license.benefit.unlimitedHistory": "Unlimited history retention", + "license.usageToday": "Usage today", + "license.perDay": "/day", + "license.unlimited": "Unlimited", + "license.locked": "Locked", + "license.nav": "License" } diff --git a/src/renderer/i18n/es.json b/src/renderer/i18n/es.json new file mode 100644 index 0000000..9599f6e --- /dev/null +++ b/src/renderer/i18n/es.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "Asistente de voz con IA local", + + "nav.dashboard": "Panel", + "nav.history": "Historial", + "nav.dictionary": "Diccionario", + "nav.commands": "Comandos", + "nav.settings": "Ajustes", + + "dashboard.sessionOverview": "Resumen de sesión", + "dashboard.systemStatus": "Estado del sistema", + "dashboard.sessionsToday": "Sesiones hoy", + "dashboard.pressToRecord": "Pulsa {{key}} para empezar a grabar", + "dashboard.hotkeyNotSet": "Atajo no configurado", + "dashboard.words": "palabras", + "dashboard.total": "total", + "dashboard.streak": "racha", + "dashboard.days": "días", + "dashboard.recording": "grabando", + "dashboard.sessions": "sesiones", + "dashboard.today": "hoy", + "dashboard.recentTranscriptions": "Transcripciones recientes", + "dashboard.noHistory": "Sin historial — pulsa el atajo para empezar a grabar", + "dashboard.copy": "Copiar", + "dashboard.entries": "{{count}} entradas", + "dashboard.stat": "estadísticas", + "dashboard.sys": "sistema", + + "history.title": "Historial de transcripciones", + "history.search": "Buscar...", + "history.entries": "{{count}} entradas", + "history.loading": "Cargando...", + "history.noResults": "Sin resultados", + "history.noHistory": "Sin historial — empieza a grabar", + "history.count": "{{count}} entradas", + + "dictionary.title": "Diccionario personalizado", + "dictionary.words": "{{count}} palabras", + "dictionary.add": "Añadir", + "dictionary.search": "Buscar...", + "dictionary.loading": "Cargando...", + "dictionary.noResults": "Sin resultados", + "dictionary.noWords": "Sin palabras — añade palabras personalizadas para mejorar la precisión del STT", + "dictionary.used": "usado {{count}} veces", + "dictionary.editTitle": "Editar palabra", + "dictionary.addTitle": "Añadir palabra", + "dictionary.word": "Palabra", + "dictionary.pronunciation": "Pronunciación (opcional)", + + "commands.title": "Comandos LLM", + "commands.count": "{{count}} comandos", + "commands.add": "Añadir", + "commands.activeCommand": "Comando activo", + "commands.none": "Ninguno", + "commands.loading": "Cargando...", + "commands.noCommands": "Sin comandos — haz clic en añadir para crear uno", + "commands.editTitle": "Editar comando", + "commands.addTitle": "Añadir comando", + "commands.name": "Nombre", + "commands.description": "Descripción", + "commands.promptTemplate": "Plantilla de prompt", + "commands.promptHelp": "{{text}} se reemplazará por el texto transcrito", + "commands.defaultPrompt": "Por favor, mejora {{text}}.", + + "settings.title": "Ajustes", + "settings.tabs.general": "General", + "settings.tabs.audio": "Audio", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "Acerca de", + + "settings.shortcuts": "Atajos", + "settings.dictation": "Dictado", + "settings.dictation.desc": "Mantén pulsado para hablar. Al soltar, comienza la transcripción.", + "settings.agent": "Modo agente", + "settings.agent.descWithKey": "Haz doble clic en {{key}} para entrar en el modo agente.", + "settings.agent.descNoKey": "Primero configura el atajo de dictado.", + "settings.oneTouch": "Modo un toque", + "settings.oneTouch.desc": "Pulsa para iniciar, pulsa de nuevo para detener. Requiere un atajo independiente.", + "settings.key": "Tecla", + "settings.notSet": "No configurado", + "settings.enabled": "Activado", + "settings.disabled": "Desactivado", + + "settings.interface": "Interfaz", + "settings.theme": "Tema", + "settings.theme.system": "Sistema", + "settings.theme.light": "Claro", + "settings.theme.dark": "Oscuro", + "settings.language": "Idioma", + + "settings.appBehavior": "Comportamiento de la app", + "settings.closeToTray": "Minimizar a la bandeja", + "settings.autoLaunch": "Iniciar con el sistema", + "settings.autoInsert": "Insertar texto automáticamente tras transcripción", + "settings.soundEffects": "Efectos de sonido", + + "settings.microphone": "Micrófono", + "settings.inputDevice": "Dispositivo de entrada", + "settings.deviceDefault": "(predeterminado)", + "settings.textInsert": "Inserción de texto", + "settings.insertMethod": "Método de inserción", + "settings.insertClipboard": "Portapapeles (Ctrl+V)", + "settings.insertKeyboard": "Escritura con teclado", + + "settings.whisperModel": "Modelo Whisper", + "settings.model.tiny": "tiny (39 MB, más rápido)", + "settings.model.base": "base (74 MB, equilibrado)", + "settings.model.small": "small (244 MB, bueno)", + "settings.model.medium": "medium (769 MB, muy bueno)", + "settings.model.large": "large-v3 (1.5 GB, mejor)", + "settings.sttLanguage": "Idioma de reconocimiento", + "settings.sttLang.auto": "Detección automática", + + "settings.ollamaServer": "Servidor Ollama", + "settings.ollamaUrl": "URL del servidor Ollama", + "settings.ollamaHint": "D3RO Voice se conecta automáticamente cuando Ollama está en ejecución. Descarga modelos directamente desde Ollama (ej: ollama pull qwen3:4b).", + "settings.postProcess": "Postprocesado de voz", + "settings.defaultAction": "Acción de postprocesado predeterminada", + "settings.action.none": "Ninguna (texto original sin cambios)", + "settings.action.refine": "Refinar (gramática + naturalidad)", + "settings.action.translate": "Traducir", + "settings.action.summarize": "Resumir", + "settings.action.grammar": "Corrección gramatical", + "settings.action.custom": "Prompt personalizado", + "settings.actionHint": "El postprocesado LLM seleccionado se aplica al texto transcrito tras grabar con el atajo. Solo funciona cuando Ollama está conectado.", + + "settings.about.version": "Versión", + "settings.about.techStack": "Stack tecnológico", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Motor de voz", + "settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)", + "settings.about.description": "Asistente de voz con IA completamente local, basado en ingeniería inversa de Speakly. Funciona sin dependencias en la nube.", + "settings.about.restartOnboarding": "Ver guía de configuración inicial de nuevo", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama no está en ejecución", + "status.nudge.desc": "Se necesita Ollama para el postprocesado LLM (traducción, resumen, etc.).", + "status.nudge.guide": "Ver guía de instalación →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "Sin escribir, con voz. Tu asistente de voz con IA local.", + "onboarding.welcome.start": "Comenzar", + "onboarding.mic.title": "Configurar micrófono", + "onboarding.mic.desc": "Selecciona el micrófono que quieres usar. Puedes cambiarlo más tarde en los ajustes.", + "onboarding.hotkey.title": "Configurar atajo", + "onboarding.hotkey.desc": "Configura el atajo de dictado. Se grabará mientras mantengas la tecla pulsada.", + "onboarding.hotkey.notSet": "No se ha configurado ningún atajo", + "onboarding.hotkey.change": "Cambiar atajo", + "onboarding.hotkey.set": "Configurar atajo", + "onboarding.ollama.title": "Instalar Ollama (opcional)", + "onboarding.ollama.desc": "Se necesita Ollama para el postprocesado LLM como traducción o resumen. El dictado de voz funciona sin Ollama.", + "onboarding.ollama.download": "Descargar Ollama", + "onboarding.ollama.modelHint": "Tras instalar, descarga un modelo desde la terminal", + "onboarding.done.title": "¡Configuración completa!", + "onboarding.done.descWithKey": "Mantén pulsado {{key}} y habla para convertir tu voz en texto.", + "onboarding.done.descNoKey": "Configura un atajo en los ajustes para empezar a dictar.", + "onboarding.done.start": "Comenzar", + "onboarding.back": "Atrás", + "onboarding.next": "Siguiente", + + "hotkey.title": "Configurar atajo", + "hotkey.dictationTitle": "Configurar atajo de dictado", + "hotkey.oneTouchTitle": "Configurar atajo de modo un toque", + "hotkey.prompt": "Pulsa una combinación de teclas...", + "hotkey.ready": "✓ {{keys}} — pulsa guardar", + "hotkey.noKey": "Por favor, introduce una tecla", + "hotkey.reserved": "{{keys}} es un atajo reservado por el sistema", + "hotkey.current": "Actual: {{keys}}", + "hotkey.hint": "Introduce una combinación (ej: Ctrl+Shift+Q) o una tecla sola (ej: F5)", + "hotkey.reset": "Volver a introducir", + + "ollama.title": "GUÍA DE CONFIGURACIÓN DE OLLAMA", + "ollama.step1.title": "PASO 1 — Instalar Ollama", + "ollama.step1.desc": "Ollama es una herramienta gratuita para ejecutar LLMs de forma local.", + "ollama.step2.title": "PASO 2 — Descargar un modelo", + "ollama.step2.desc": "Ejecuta el siguiente comando en la terminal para descargar el modelo recomendado:", + "ollama.step2.alt": "O un modelo más grande: ollama pull qwen3:8b (más preciso, más lento)", + "ollama.step3.title": "PASO 3 — Conexión automática", + "ollama.step3.desc": "D3RO-VOICE detecta automáticamente cuando Ollama está en ejecución. Cuando el LED de la barra de estado pase de rojo a verde, ¡estará listo!", + + "service.sttEngine": "MOTOR STT", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "ATAJO DE TECLADO", + "service.audioInput": "ENTRADA DE AUDIO", + "service.ready": "LISTO", + "service.connected": "CONECTADO", + "service.offline": "OFFLINE", + "service.active": "ACTIVO", + "service.standby": "EN ESPERA", + + "common.cancel": "Cancelar", + "common.save": "Guardar", + "common.delete": "Eliminar", + "common.add": "Añadir", + "common.edit": "Editar", + "common.close": "Cerrar", + "common.confirm": "Confirmar", + "common.loading": "Cargando...", + "common.copy": "Copiar", + "common.test": "Probar", + "common.stop": "Detener", + + "date.today": "Hoy", + "date.yesterday": "Ayer" +} diff --git a/src/renderer/i18n/fr.json b/src/renderer/i18n/fr.json new file mode 100644 index 0000000..e8a54ef --- /dev/null +++ b/src/renderer/i18n/fr.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "Assistant vocal IA local", + + "nav.dashboard": "Tableau de bord", + "nav.history": "Historique", + "nav.dictionary": "Dictionnaire", + "nav.commands": "Commandes", + "nav.settings": "Paramètres", + + "dashboard.sessionOverview": "Aperçu de session", + "dashboard.systemStatus": "État du système", + "dashboard.sessionsToday": "Sessions aujourd'hui", + "dashboard.pressToRecord": "Appuyez sur {{key}} pour commencer l'enregistrement", + "dashboard.hotkeyNotSet": "Raccourci non configuré", + "dashboard.words": "mots", + "dashboard.total": "total", + "dashboard.streak": "série", + "dashboard.days": "jours", + "dashboard.recording": "enregistrement", + "dashboard.sessions": "sessions", + "dashboard.today": "aujourd'hui", + "dashboard.recentTranscriptions": "Transcriptions récentes", + "dashboard.noHistory": "Aucun historique — appuyez sur le raccourci pour commencer à enregistrer", + "dashboard.copy": "Copier", + "dashboard.entries": "{{count}} entrées", + "dashboard.stat": "statistiques", + "dashboard.sys": "système", + + "history.title": "Historique des transcriptions", + "history.search": "Rechercher...", + "history.entries": "{{count}} entrées", + "history.loading": "Chargement...", + "history.noResults": "Aucun résultat", + "history.noHistory": "Aucun historique — commencez à enregistrer", + "history.count": "{{count}} entrées", + + "dictionary.title": "Dictionnaire personnalisé", + "dictionary.words": "{{count}} mots", + "dictionary.add": "Ajouter", + "dictionary.search": "Rechercher...", + "dictionary.loading": "Chargement...", + "dictionary.noResults": "Aucun résultat", + "dictionary.noWords": "Aucun mot — ajoutez des mots personnalisés pour améliorer la précision du STT", + "dictionary.used": "utilisé {{count}} fois", + "dictionary.editTitle": "Modifier le mot", + "dictionary.addTitle": "Ajouter un mot", + "dictionary.word": "Mot", + "dictionary.pronunciation": "Prononciation (facultatif)", + + "commands.title": "Commandes LLM", + "commands.count": "{{count}} commandes", + "commands.add": "Ajouter", + "commands.activeCommand": "Commande active", + "commands.none": "Aucune", + "commands.loading": "Chargement...", + "commands.noCommands": "Aucune commande — cliquez sur ajouter pour en créer une", + "commands.editTitle": "Modifier la commande", + "commands.addTitle": "Ajouter une commande", + "commands.name": "Nom", + "commands.description": "Description", + "commands.promptTemplate": "Modèle de prompt", + "commands.promptHelp": "{{text}} sera remplacé par le texte transcrit", + "commands.defaultPrompt": "Veuillez améliorer {{text}}.", + + "settings.title": "Paramètres", + "settings.tabs.general": "Général", + "settings.tabs.audio": "Audio", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "À propos", + + "settings.shortcuts": "Raccourcis", + "settings.dictation": "Dictée", + "settings.dictation.desc": "Maintenez enfoncé pour parler. La transcription démarre au relâchement.", + "settings.agent": "Mode agent", + "settings.agent.descWithKey": "Double-cliquez sur {{key}} pour entrer en mode agent.", + "settings.agent.descNoKey": "Configurez d'abord le raccourci de dictée.", + "settings.oneTouch": "Mode une touche", + "settings.oneTouch.desc": "Appuyez pour démarrer, appuyez à nouveau pour arrêter. Nécessite un raccourci distinct.", + "settings.key": "Touche", + "settings.notSet": "Non configuré", + "settings.enabled": "Activé", + "settings.disabled": "Désactivé", + + "settings.interface": "Interface", + "settings.theme": "Thème", + "settings.theme.system": "Système", + "settings.theme.light": "Clair", + "settings.theme.dark": "Sombre", + "settings.language": "Langue", + + "settings.appBehavior": "Comportement de l'application", + "settings.closeToTray": "Réduire dans la barre système", + "settings.autoLaunch": "Lancer au démarrage du système", + "settings.autoInsert": "Insérer le texte automatiquement après la transcription", + "settings.soundEffects": "Effets sonores", + + "settings.microphone": "Microphone", + "settings.inputDevice": "Périphérique d'entrée", + "settings.deviceDefault": "(par défaut)", + "settings.textInsert": "Insertion de texte", + "settings.insertMethod": "Méthode d'insertion", + "settings.insertClipboard": "Presse-papiers (Ctrl+V)", + "settings.insertKeyboard": "Frappe au clavier", + + "settings.whisperModel": "Modèle Whisper", + "settings.model.tiny": "tiny (39 Mo, le plus rapide)", + "settings.model.base": "base (74 Mo, équilibré)", + "settings.model.small": "small (244 Mo, bon)", + "settings.model.medium": "medium (769 Mo, très bon)", + "settings.model.large": "large-v3 (1,5 Go, meilleur)", + "settings.sttLanguage": "Langue de reconnaissance", + "settings.sttLang.auto": "Détection automatique", + + "settings.ollamaServer": "Serveur Ollama", + "settings.ollamaUrl": "URL du serveur Ollama", + "settings.ollamaHint": "D3RO Voice se connecte automatiquement quand Ollama est en cours d'exécution. Téléchargez les modèles directement depuis Ollama (ex : ollama pull qwen3:4b).", + "settings.postProcess": "Post-traitement vocal", + "settings.defaultAction": "Action de post-traitement par défaut", + "settings.action.none": "Aucune (texte original inchangé)", + "settings.action.refine": "Affiner (grammaire + naturel)", + "settings.action.translate": "Traduire", + "settings.action.summarize": "Résumer", + "settings.action.grammar": "Correction grammaticale", + "settings.action.custom": "Prompt personnalisé", + "settings.actionHint": "Le post-traitement LLM sélectionné est appliqué au texte transcrit après l'enregistrement avec le raccourci. Ne fonctionne que lorsque Ollama est connecté.", + + "settings.about.version": "Version", + "settings.about.techStack": "Stack technologique", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Moteur vocal", + "settings.about.voiceEngineValue": "STT : faster-whisper (local) / LLM : Ollama (local)", + "settings.about.description": "Assistant vocal IA entièrement local, basé sur l'ingénierie inverse de Speakly. Fonctionne sans dépendance au cloud.", + "settings.about.restartOnboarding": "Revoir le guide de configuration initiale", + + "status.ollama": "OLLAMA", + "status.offline": "HORS LIGNE", + "status.nudge.title": "Ollama n'est pas en cours d'exécution", + "status.nudge.desc": "Ollama est nécessaire pour le post-traitement LLM (traduction, résumé, etc.).", + "status.nudge.guide": "Voir le guide d'installation →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "Sans taper, par la voix. Votre assistant vocal IA local.", + "onboarding.welcome.start": "Commencer", + "onboarding.mic.title": "Configuration du microphone", + "onboarding.mic.desc": "Sélectionnez le microphone que vous souhaitez utiliser. Vous pourrez le modifier plus tard dans les paramètres.", + "onboarding.hotkey.title": "Configuration du raccourci", + "onboarding.hotkey.desc": "Configurez le raccourci de dictée. L'enregistrement dure tant que vous maintenez la touche enfoncée.", + "onboarding.hotkey.notSet": "Aucun raccourci configuré", + "onboarding.hotkey.change": "Modifier le raccourci", + "onboarding.hotkey.set": "Configurer le raccourci", + "onboarding.ollama.title": "Installer Ollama (facultatif)", + "onboarding.ollama.desc": "Ollama est nécessaire pour le post-traitement LLM comme la traduction ou le résumé. La dictée vocale fonctionne sans Ollama.", + "onboarding.ollama.download": "Télécharger Ollama", + "onboarding.ollama.modelHint": "Après l'installation, téléchargez un modèle depuis le terminal", + "onboarding.done.title": "Configuration terminée !", + "onboarding.done.descWithKey": "Maintenez {{key}} enfoncé et parlez pour convertir votre voix en texte.", + "onboarding.done.descNoKey": "Configurez un raccourci dans les paramètres pour commencer la dictée.", + "onboarding.done.start": "Commencer", + "onboarding.back": "Retour", + "onboarding.next": "Suivant", + + "hotkey.title": "Configurer le raccourci", + "hotkey.dictationTitle": "Configurer le raccourci de dictée", + "hotkey.oneTouchTitle": "Configurer le raccourci du mode une touche", + "hotkey.prompt": "Appuyez sur une combinaison de touches...", + "hotkey.ready": "✓ {{keys}} — appuyez sur enregistrer", + "hotkey.noKey": "Veuillez saisir une touche", + "hotkey.reserved": "{{keys}} est un raccourci réservé par le système", + "hotkey.current": "Actuel : {{keys}}", + "hotkey.hint": "Saisissez une combinaison (ex : Ctrl+Shift+Q) ou une touche seule (ex : F5)", + "hotkey.reset": "Ressaisir", + + "ollama.title": "GUIDE DE CONFIGURATION OLLAMA", + "ollama.step1.title": "ÉTAPE 1 — Installer Ollama", + "ollama.step1.desc": "Ollama est un outil gratuit pour exécuter des LLM en local.", + "ollama.step2.title": "ÉTAPE 2 — Télécharger un modèle", + "ollama.step2.desc": "Exécutez la commande suivante dans le terminal pour télécharger le modèle recommandé :", + "ollama.step2.alt": "Ou un modèle plus grand : ollama pull qwen3:8b (plus précis, plus lent)", + "ollama.step3.title": "ÉTAPE 3 — Connexion automatique", + "ollama.step3.desc": "D3RO-VOICE détecte automatiquement quand Ollama est en cours d'exécution. Quand la LED de la barre d'état passe du rouge au vert, c'est prêt !", + + "service.sttEngine": "MOTEUR STT", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "RACCOURCI CLAVIER", + "service.audioInput": "ENTRÉE AUDIO", + "service.ready": "PRÊT", + "service.connected": "CONNECTÉ", + "service.offline": "HORS LIGNE", + "service.active": "ACTIF", + "service.standby": "EN VEILLE", + + "common.cancel": "Annuler", + "common.save": "Enregistrer", + "common.delete": "Supprimer", + "common.add": "Ajouter", + "common.edit": "Modifier", + "common.close": "Fermer", + "common.confirm": "Confirmer", + "common.loading": "Chargement...", + "common.copy": "Copier", + "common.test": "Tester", + "common.stop": "Arrêter", + + "date.today": "Aujourd'hui", + "date.yesterday": "Hier" +} diff --git a/src/renderer/i18n/index.ts b/src/renderer/i18n/index.ts deleted file mode 100644 index 3c7022a..0000000 --- a/src/renderer/i18n/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -// src/renderer/i18n/index.ts -// 간단한 i18n 유틸리티. 설계서 Phase 6. - -import { createContext, useContext } from 'react' -import ko from './ko.json' -import en from './en.json' - -type Locale = 'ko' | 'en' -type Translations = Record - -const locales: Record = { ko, en } - -let currentLocale: Locale = 'ko' -let currentTranslations: Translations = ko - -export function setLocale(locale: Locale): void { - currentLocale = locale - currentTranslations = locales[locale] ?? ko -} - -export function getLocale(): Locale { - return currentLocale -} - -export function t(key: string, params?: Record): string { - let text = currentTranslations[key] ?? ko[key as keyof typeof ko] ?? key - - if (params) { - for (const [k, v] of Object.entries(params)) { - text = text.replace(`{{${k}}}`, v) - } - } - - return text -} - -// React 컨텍스트 -interface I18nContextValue { - locale: Locale - t: typeof t -} - -export const I18nContext = createContext({ - locale: 'ko', - t -}) - -export function useI18n(): I18nContextValue { - return useContext(I18nContext) -} diff --git a/src/renderer/i18n/index.tsx b/src/renderer/i18n/index.tsx new file mode 100644 index 0000000..1bd6413 --- /dev/null +++ b/src/renderer/i18n/index.tsx @@ -0,0 +1,204 @@ +// src/renderer/i18n/index.ts +// SSOT i18n 엔진: 타입 안전 키, React Context, fallback 체인, Intl 포맷팅 +// 마스터: ko.json — 모든 키의 단일 소스 + +import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react' +import type { ReactNode } from 'react' +import ko from './ko.json' +import en from './en.json' +import ja from './ja.json' +import zh from './zh.json' +import zhTW from './zh-TW.json' +import es from './es.json' +import fr from './fr.json' +import de from './de.json' +import pt from './pt.json' +import ru from './ru.json' +import vi from './vi.json' +import th from './th.json' + +// ── 타입 ──────────────────────────────────────────────── +/** 마스터 키에서 자동 추출된 번역 키 유니온 */ +export type TranslationKey = keyof typeof ko + +/** 지원 로케일 */ +export type Locale = + | 'ko' | 'en' | 'ja' | 'zh' | 'zh-TW' + | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi' | 'th' + +type Translations = Record + +// ── 로케일 레지스트리 ─────────────────────────────────── +const LOCALE_MAP: Record = { + ko, + en, + ja, + zh, + 'zh-TW': zhTW, + es, + fr, + de, + pt, + ru, + vi, + th, +} + +/** 언어 선택 UI에 표시할 메타데이터 (각 언어로 된 자국어명) */ +export const LOCALE_META: ReadonlyArray<{ code: Locale; nativeName: string; englishName: string }> = [ + { code: 'ko', nativeName: '한국어', englishName: 'Korean' }, + { code: 'en', nativeName: 'English', englishName: 'English' }, + { code: 'ja', nativeName: '日本語', englishName: 'Japanese' }, + { code: 'zh', nativeName: '简体中文', englishName: 'Chinese (Simplified)' }, + { code: 'zh-TW', nativeName: '繁體中文', englishName: 'Chinese (Traditional)' }, + { code: 'es', nativeName: 'Español', englishName: 'Spanish' }, + { code: 'fr', nativeName: 'Français', englishName: 'French' }, + { code: 'de', nativeName: 'Deutsch', englishName: 'German' }, + { code: 'pt', nativeName: 'Português', englishName: 'Portuguese' }, + { code: 'ru', nativeName: 'Русский', englishName: 'Russian' }, + { code: 'vi', nativeName: 'Tiếng Việt', englishName: 'Vietnamese' }, + { code: 'th', nativeName: 'ไทย', englishName: 'Thai' }, +] + +// ── 번역 함수 ────────────────────────────────────────── +function resolveTranslation( + key: string, + translations: Translations, + params?: Record, +): string { + // fallback 체인: 현재 로케일 → en → ko → key 자체 + let text = translations[key] ?? (en as Translations)[key] ?? (ko as Translations)[key] ?? key + + if (params) { + for (const [k, v] of Object.entries(params)) { + text = text.replaceAll(`{{${k}}}`, String(v)) + } + } + + return text +} + +// ── Intl 포맷팅 유틸 ─────────────────────────────────── +function toBcp47(locale: Locale): string { + // BCP 47 태그로 변환 + const map: Partial> = { + 'zh': 'zh-CN', + 'zh-TW': 'zh-TW', + } + return map[locale] ?? locale +} + +function createFormatDate(locale: Locale) { + const bcp = toBcp47(locale) + return (date: Date | number, options?: Intl.DateTimeFormatOptions): string => { + const d = typeof date === 'number' ? new Date(date) : date + return new Intl.DateTimeFormat(bcp, options).format(d) + } +} + +function createFormatNumber(locale: Locale) { + const bcp = toBcp47(locale) + return (num: number, options?: Intl.NumberFormatOptions): string => { + return new Intl.NumberFormat(bcp, options).format(num) + } +} + +function createFormatRelativeDate(locale: Locale, t: TFunction) { + return (ts: number): string => { + const d = new Date(ts) + d.setHours(0, 0, 0, 0) + const today = new Date() + today.setHours(0, 0, 0, 0) + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + if (d.getTime() === today.getTime()) return t('date.today') + if (d.getTime() === yesterday.getTime()) return t('date.yesterday') + + const bcp = toBcp47(locale) + return new Intl.DateTimeFormat(bcp, { month: 'short', day: 'numeric' }).format(d).toUpperCase() + } +} + +function createFormatTime(locale: Locale) { + const bcp = toBcp47(locale) + return (ts: number): string => { + return new Intl.DateTimeFormat(bcp, { hour: '2-digit', minute: '2-digit' }).format(new Date(ts)) + } +} + +// ── t() 함수 타입 ────────────────────────────────────── +export type TFunction = (key: TranslationKey, params?: Record) => string + +// ── React Context ────────────────────────────────────── +export interface I18nContextValue { + locale: Locale + t: TFunction + setLocale: (locale: Locale) => void + /** Intl 기반 날짜 포맷 */ + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string + /** Intl 기반 숫자 포맷 */ + formatNumber: (num: number, options?: Intl.NumberFormatOptions) => string + /** "오늘" / "어제" / "4월 3일" 등 상대 날짜 */ + formatRelativeDate: (ts: number) => string + /** 시:분 포맷 */ + formatTime: (ts: number) => string +} + +const I18nContext = createContext(null) + +// ── Provider ─────────────────────────────────────────── +export interface I18nProviderProps { + initialLocale?: Locale + children: ReactNode +} + +export function I18nProvider({ initialLocale = 'ko', children }: I18nProviderProps): React.ReactElement { + const [locale, setLocaleState] = useState(initialLocale) + + // ConfigService에서 저장된 언어 로드 + useEffect(() => { + window.electronAPI.config.get({ key: 'language' }).then((r) => { + if (r.success && r.data && isValidLocale(r.data as string)) { + setLocaleState(r.data as Locale) + } + }) + }, []) + + const setLocale = useCallback((newLocale: Locale) => { + setLocaleState(newLocale) + // 설정에 저장 + window.electronAPI.config.set({ key: 'language', value: newLocale }) + }, []) + + const value = useMemo((): I18nContextValue => { + const translations = LOCALE_MAP[locale] ?? ko + const t: TFunction = (key, params) => resolveTranslation(key, translations, params) + + return { + locale, + t, + setLocale, + formatDate: createFormatDate(locale), + formatNumber: createFormatNumber(locale), + formatRelativeDate: createFormatRelativeDate(locale, t), + formatTime: createFormatTime(locale), + } + }, [locale, setLocale]) + + return {children} +} + +// ── Hook ─────────────────────────────────────────────── +export function useI18n(): I18nContextValue { + const ctx = useContext(I18nContext) + if (!ctx) { + throw new Error('useI18n must be used within ') + } + return ctx +} + +// ── 유틸 ─────────────────────────────────────────────── +export function isValidLocale(value: string): value is Locale { + return value in LOCALE_MAP +} diff --git a/src/renderer/i18n/ja.json b/src/renderer/i18n/ja.json new file mode 100644 index 0000000..27a62fa --- /dev/null +++ b/src/renderer/i18n/ja.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "ローカルAI音声アシスタント", + + "nav.dashboard": "ダッシュボード", + "nav.history": "履歴", + "nav.dictionary": "辞書", + "nav.commands": "コマンド", + "nav.settings": "設定", + + "dashboard.sessionOverview": "セッション概要", + "dashboard.systemStatus": "システム状態", + "dashboard.sessionsToday": "本日のセッション", + "dashboard.pressToRecord": "{{key}} を押して録音開始", + "dashboard.hotkeyNotSet": "ホットキー未設定", + "dashboard.words": "単語", + "dashboard.total": "合計", + "dashboard.streak": "連続", + "dashboard.days": "日", + "dashboard.recording": "録音", + "dashboard.sessions": "セッション", + "dashboard.today": "今日", + "dashboard.recentTranscriptions": "最近の文字起こし", + "dashboard.noHistory": "履歴なし — ホットキーを押して録音を開始してください", + "dashboard.copy": "コピー", + "dashboard.entries": "{{count}}件", + "dashboard.stat": "統計", + "dashboard.sys": "システム", + + "history.title": "文字起こし履歴", + "history.search": "検索...", + "history.entries": "{{count}}件", + "history.loading": "読み込み中...", + "history.noResults": "検索結果なし", + "history.noHistory": "履歴なし — 録音を開始してください", + "history.count": "{{count}}件", + + "dictionary.title": "カスタム辞書", + "dictionary.words": "{{count}}語", + "dictionary.add": "追加", + "dictionary.search": "検索...", + "dictionary.loading": "読み込み中...", + "dictionary.noResults": "検索結果なし", + "dictionary.noWords": "単語なし — STTの精度向上のためカスタム単語を追加してください", + "dictionary.used": "{{count}}回使用", + "dictionary.editTitle": "単語を編集", + "dictionary.addTitle": "単語を追加", + "dictionary.word": "単語", + "dictionary.pronunciation": "読み仮名 (任意)", + + "commands.title": "LLMコマンド", + "commands.count": "{{count}}個", + "commands.add": "追加", + "commands.activeCommand": "アクティブコマンド", + "commands.none": "なし", + "commands.loading": "読み込み中...", + "commands.noCommands": "コマンドなし — 追加をクリックして作成", + "commands.editTitle": "コマンドを編集", + "commands.addTitle": "コマンドを追加", + "commands.name": "名前", + "commands.description": "説明", + "commands.promptTemplate": "プロンプトテンプレート", + "commands.promptHelp": "{{text}} は文字起こしされたテキストに置換されます", + "commands.defaultPrompt": "{{text}} を整えてください。", + + "settings.title": "設定", + "settings.tabs.general": "一般", + "settings.tabs.audio": "オーディオ", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "情報", + + "settings.shortcuts": "ショートカット", + "settings.dictation": "ディクテーション", + "settings.dictation.desc": "押しながら話します。キーを離すと文字起こしが始まります。", + "settings.agent": "エージェントモード", + "settings.agent.descWithKey": "{{key}} を2回クリックするとエージェントモードに入ります。", + "settings.agent.descNoKey": "先にディクテーションホットキーを設定してください。", + "settings.oneTouch": "ワンタッチモード", + "settings.oneTouch.desc": "押して開始、もう一度押して停止。別のショートカットが必要です。", + "settings.key": "キー", + "settings.notSet": "未設定", + "settings.enabled": "有効", + "settings.disabled": "無効", + + "settings.interface": "インターフェース", + "settings.theme": "テーマ", + "settings.theme.system": "システム", + "settings.theme.light": "ライト", + "settings.theme.dark": "ダーク", + "settings.language": "言語", + + "settings.appBehavior": "アプリの動作", + "settings.closeToTray": "トレイに最小化", + "settings.autoLaunch": "システム起動時に自動起動", + "settings.autoInsert": "文字起こし後に自動テキスト挿入", + "settings.soundEffects": "効果音", + + "settings.microphone": "マイク", + "settings.inputDevice": "入力デバイス", + "settings.deviceDefault": "(デフォルト)", + "settings.textInsert": "テキスト挿入", + "settings.insertMethod": "挿入方式", + "settings.insertClipboard": "クリップボード (Ctrl+V)", + "settings.insertKeyboard": "キーボード入力", + + "settings.whisperModel": "Whisperモデル", + "settings.model.tiny": "tiny (39 MB, 最速)", + "settings.model.base": "base (74 MB, バランス)", + "settings.model.small": "small (244 MB, 良好)", + "settings.model.medium": "medium (769 MB, 高精度)", + "settings.model.large": "large-v3 (1.5 GB, 最高)", + "settings.sttLanguage": "認識言語", + "settings.sttLang.auto": "自動検出", + + "settings.ollamaServer": "Ollamaサーバー", + "settings.ollamaUrl": "OllamaサーバーURL", + "settings.ollamaHint": "Ollamaが起動していれば自動的に接続されます。モデルはOllamaから直接pullしてください(例: ollama pull qwen3:4b)。", + "settings.postProcess": "音声後処理", + "settings.defaultAction": "デフォルト後処理コマンド", + "settings.action.none": "なし (元のテキストをそのまま)", + "settings.action.refine": "整形 (文法+自然さ)", + "settings.action.translate": "翻訳", + "settings.action.summarize": "要約", + "settings.action.grammar": "文法修正", + "settings.action.custom": "カスタムプロンプト", + "settings.actionHint": "ホットキーで録音後、文字起こしされたテキストに選択したLLM後処理が適用されます。Ollamaが接続されている場合のみ動作します。", + + "settings.about.version": "バージョン", + "settings.about.techStack": "技術スタック", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "音声エンジン", + "settings.about.voiceEngineValue": "STT: faster-whisper (ローカル) / LLM: Ollama (ローカル)", + "settings.about.description": "Speaklyリバースエンジニアリングのノウハウをもとに構築したローカルAI音声アシスタント。クラウドに依存せず完全ローカルで動作します。", + "settings.about.restartOnboarding": "初期設定ガイドをもう一度見る", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollamaが起動していません", + "status.nudge.desc": "LLM後処理(翻訳、要約など)を使用するにはOllamaが必要です。", + "status.nudge.guide": "インストールガイドを見る →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "タイピング不要、音声で。ローカルAI音声アシスタントです。", + "onboarding.welcome.start": "始める", + "onboarding.mic.title": "マイク設定", + "onboarding.mic.desc": "使用するマイクを選択してください。後で設定から変更できます。", + "onboarding.hotkey.title": "ショートカット設定", + "onboarding.hotkey.desc": "ディクテーションショートカットを設定してください。キーを押している間、録音されます。", + "onboarding.hotkey.notSet": "ショートカットが設定されていません", + "onboarding.hotkey.change": "ショートカットを変更", + "onboarding.hotkey.set": "ショートカットを設定", + "onboarding.ollama.title": "Ollamaのインストール (任意)", + "onboarding.ollama.desc": "翻訳、要約などのLLM後処理を使用するにはOllamaが必要です。音声ディクテーション自体はOllamaなしでも動作します。", + "onboarding.ollama.download": "Ollamaをダウンロード", + "onboarding.ollama.modelHint": "インストール後、ターミナルでモデルをダウンロードしてください", + "onboarding.done.title": "設定完了!", + "onboarding.done.descWithKey": "{{key}} キーを押しながら話すと、音声がテキストに変換されます。", + "onboarding.done.descNoKey": "設定でショートカットを指定すると、音声ディクテーションを開始できます。", + "onboarding.done.start": "始める", + "onboarding.back": "戻る", + "onboarding.next": "次へ", + + "hotkey.title": "ショートカット設定", + "hotkey.dictationTitle": "ディクテーションショートカットの設定", + "hotkey.oneTouchTitle": "ワンタッチモードショートカットの設定", + "hotkey.prompt": "キーの組み合わせを押してください...", + "hotkey.ready": "✓ {{keys}} — 保存を押してください", + "hotkey.noKey": "キーを入力してください", + "hotkey.reserved": "{{keys}} はシステム予約のショートカットです", + "hotkey.current": "現在: {{keys}}", + "hotkey.hint": "組み合わせキー(例: Ctrl+Shift+Q)または単一キー(例: F5)を入力してください", + "hotkey.reset": "再入力", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "STEP 1 — Ollamaのインストール", + "ollama.step1.desc": "OllamaはローカルでLLMを実行するための無料ツールです。", + "ollama.step2.title": "STEP 2 — モデルのダウンロード", + "ollama.step2.desc": "ターミナルで希望のモデルをpullしてください。日本語におすすめ:", + "ollama.step2.alt": "または大きいモデル: ollama pull qwen3:8b (より正確、より遅い)", + "ollama.step3.title": "STEP 3 — 自動接続", + "ollama.step3.desc": "Ollamaが起動するとD3RO-VOICEが自動的に検出します。下部ステータスバーのLEDが赤から緑に変われば準備完了!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + + "common.cancel": "キャンセル", + "common.save": "保存", + "common.delete": "削除", + "common.add": "追加", + "common.edit": "編集", + "common.close": "閉じる", + "common.confirm": "確認", + "common.loading": "読み込み中...", + "common.copy": "コピー", + "common.test": "テスト", + "common.stop": "停止", + + "date.today": "今日", + "date.yesterday": "昨日" +} diff --git a/src/renderer/i18n/ko.json b/src/renderer/i18n/ko.json index 79173d1..c1085d0 100644 --- a/src/renderer/i18n/ko.json +++ b/src/renderer/i18n/ko.json @@ -1,58 +1,331 @@ { "app.name": "D3RO Voice", + "app.tagline": "로컬 AI 음성 어시스턴트", + "nav.dashboard": "대시보드", "nav.history": "히스토리", "nav.dictionary": "사전", "nav.commands": "명령어", "nav.settings": "설정", - "dashboard.title": "대시보드", - "dashboard.totalSessions": "총 세션", - "dashboard.totalTime": "총 시간", - "dashboard.totalWords": "총 단어", - "dashboard.streak": "연속", - "dashboard.today": "오늘", - "dashboard.sessions": "세션", - "dashboard.time": "시간", + + "dashboard.sessionOverview": "세션 개요", + "dashboard.systemStatus": "시스템 상태", + "dashboard.sessionsToday": "오늘 세션", + "dashboard.pressToRecord": "{{key}}를 눌러 녹음 시작", + "dashboard.hotkeyNotSet": "핫키 미설정", "dashboard.words": "단어", - "history.title": "히스토리", - "history.search": "전사 내용 검색...", - "history.empty": "아직 히스토리가 없습니다.", - "history.noResults": "검색 결과가 없습니다.", - "dictionary.title": "사전", - "dictionary.search": "단어 검색...", - "dictionary.addWord": "단어 추가", - "dictionary.empty": "아직 등록된 단어가 없습니다. STT 정확도 향상을 위해 커스텀 단어를 추가하세요.", - "dictionary.noResults": "검색 결과가 없습니다.", + "dashboard.total": "전체", + "dashboard.streak": "연속", + "dashboard.days": "일", + "dashboard.recording": "녹음", + "dashboard.sessions": "세션", + "dashboard.today": "오늘", + "dashboard.recentTranscriptions": "최근 전사", + "dashboard.noHistory": "히스토리 없음 — 핫키를 눌러 녹음을 시작하세요", + "dashboard.copy": "복사", + "dashboard.entries": "{{count}}건", + "dashboard.stat": "통계", + "dashboard.sys": "시스템", + + "history.title": "전사 기록", + "history.search": "검색...", + "history.entries": "{{count}}건", + "history.loading": "로딩...", + "history.noResults": "검색 결과 없음", + "history.noHistory": "히스토리 없음 — 녹음을 시작하세요", + "history.count": "{{count}}건", + + "dictionary.title": "커스텀 사전", + "dictionary.words": "{{count}}개", + "dictionary.add": "추가", + "dictionary.search": "검색...", + "dictionary.loading": "로딩...", + "dictionary.noResults": "검색 결과 없음", + "dictionary.noWords": "단어 없음 — STT 정확도 향상을 위해 커스텀 단어를 추가하세요", + "dictionary.used": "{{count}}회 사용", + "dictionary.editTitle": "단어 편집", + "dictionary.addTitle": "단어 추가", "dictionary.word": "단어", "dictionary.pronunciation": "발음 (선택)", - "commands.title": "커스텀 명령어", - "commands.add": "명령어 추가", + + "commands.title": "LLM 명령어", + "commands.count": "{{count}}개", + "commands.add": "추가", + "commands.activeCommand": "활성 명령어", + "commands.none": "없음", + "commands.loading": "로딩...", + "commands.noCommands": "명령어 없음 — 추가를 클릭하여 생성", + "commands.editTitle": "명령어 편집", + "commands.addTitle": "명령어 추가", "commands.name": "이름", "commands.description": "설명", - "commands.prompt": "프롬프트", - "commands.builtin": "기본", - "commands.custom": "사용자", + "commands.promptTemplate": "프롬프트 템플릿", + "commands.promptHelp": "{{text}}는 전사된 텍스트로 치환됩니다", + "commands.defaultPrompt": "{{text}}를 다듬어주세요.", + "settings.title": "설정", - "settings.general": "일반", - "settings.audio": "오디오", - "settings.stt": "음성 인식", - "settings.llm": "LLM", + "settings.tabs.general": "일반", + "settings.tabs.audio": "오디오", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "정보", + + "settings.shortcuts": "단축키", + "settings.dictation": "받아쓰기", + "settings.dictation.desc": "누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다.", + "settings.agent": "Agent 모드", + "settings.agent.descWithKey": "{{key}}를 두 번 클릭하면 Agent 모드에 진입합니다.", + "settings.agent.descNoKey": "받아쓰기 핫키를 먼저 설정하세요.", + "settings.oneTouch": "원터치 모드", + "settings.oneTouch.desc": "눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다.", + "settings.caption": "실시간 자막", + "settings.caption.desc": "핫키로 자막 모드를 토글합니다. 마이크 입력을 실시간 전사합니다.", + "settings.captionAudio": "자막 오디오 소스", + "settings.captionSource": "오디오 소스", + "settings.captionSource.mic": "마이크만", + "settings.captionSource.system": "시스템 오디오만 (데스크톱 소리)", + "settings.captionSource.both": "마이크 + 시스템 오디오", + "settings.key": "키", + "settings.notSet": "미설정", + "settings.enabled": "활성화됨", + "settings.disabled": "비활성화", + + "settings.interface": "인터페이스", "settings.theme": "테마", + "settings.theme.system": "시스템", + "settings.theme.light": "라이트", + "settings.theme.dark": "다크", "settings.language": "언어", - "settings.closeToTray": "닫기 시 트레이로 최소화", - "settings.autoInsert": "전사 후 자동 삽입", + + "settings.appBehavior": "앱 동작", + "settings.closeToTray": "트레이로 최소화", + "settings.autoLaunch": "시스템 시작 시 자동 실행", + "settings.autoInsert": "전사 후 자동 텍스트 삽입", "settings.soundEffects": "효과음", + + "settings.microphone": "마이크", + "settings.inputDevice": "입력 장치", + "settings.deviceDefault": "(기본)", + "settings.textInsert": "텍스트 삽입", "settings.insertMethod": "삽입 방식", + "settings.insertClipboard": "클립보드 (Ctrl+V)", + "settings.insertKeyboard": "키보드 타이핑", + "settings.whisperModel": "Whisper 모델", + "settings.model.tiny": "tiny (39 MB, 가장 빠름)", + "settings.model.base": "base (74 MB, 균형)", + "settings.model.small": "small (244 MB, 양호)", + "settings.model.medium": "medium (769 MB, 우수)", + "settings.model.large": "large-v3 (1.5 GB, 최고)", "settings.sttLanguage": "인식 언어", + "settings.sttLang.auto": "자동 감지", + + "settings.ollamaServer": "Ollama 서버", "settings.ollamaUrl": "Ollama 서버 URL", + "settings.ollamaHint": "Ollama가 실행 중이면 자동으로 연결됩니다. 모델은 Ollama에서 직접 pull하세요 (예: ollama pull qwen3:4b).", + "settings.postProcess": "음성 후처리", + "settings.defaultAction": "기본 후처리 명령어", + "settings.action.none": "없음 (원본 텍스트 그대로)", + "settings.action.refine": "다듬기 (문법+자연스러움)", + "settings.action.translate": "번역", + "settings.action.summarize": "요약", + "settings.action.grammar": "문법 교정", + "settings.action.custom": "커스텀 프롬프트", + "settings.actionHint": "핫키로 녹음 후 전사된 텍스트에 선택한 LLM 후처리가 적용됩니다. Ollama가 연결되어 있을 때만 동작합니다.", + + "settings.about.version": "버전", + "settings.about.techStack": "기술 스택", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "음성 엔진", + "settings.about.voiceEngineValue": "STT: faster-whisper (로컬) / LLM: Ollama (로컬)", + "settings.about.description": "Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. 클라우드 의존성 없이 완전 로컬로 동작합니다.", + "settings.about.restartOnboarding": "초기 설정 안내 다시 보기", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama가 실행되지 않고 있어요", + "status.nudge.desc": "LLM 후처리(번역, 요약 등)를 사용하려면 Ollama가 필요합니다.", + "status.nudge.guide": "설치 안내 보기 →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "타이핑 없이, 음성으로. 로컬 AI 음성 어시스턴트입니다.", + "onboarding.welcome.start": "시작하기", + "onboarding.mic.title": "마이크 설정", + "onboarding.mic.desc": "사용할 마이크를 선택하세요. 나중에 설정에서 변경할 수 있습니다.", + "onboarding.hotkey.title": "단축키 설정", + "onboarding.hotkey.desc": "받아쓰기 단축키를 설정하세요. 키를 누르고 있는 동안 녹음됩니다.", + "onboarding.hotkey.notSet": "단축키가 설정되지 않았습니다", + "onboarding.hotkey.change": "단축키 변경", + "onboarding.hotkey.set": "단축키 설정", + "onboarding.ollama.title": "Ollama 설치 (선택)", + "onboarding.ollama.desc": "번역, 요약 등 LLM 후처리를 사용하려면 Ollama가 필요합니다. 음성 받아쓰기 자체는 Ollama 없이도 동작합니다.", + "onboarding.ollama.download": "Ollama 다운로드", + "onboarding.ollama.modelHint": "설치 후 터미널에서 모델을 다운로드하세요", + "onboarding.done.title": "설정 완료!", + "onboarding.done.descWithKey": "{{key}} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.", + "onboarding.done.descNoKey": "설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.", + "onboarding.done.start": "시작하기", + "onboarding.back": "뒤로", + "onboarding.next": "다음", + + "hotkey.title": "단축키 설정", + "hotkey.dictationTitle": "받아쓰기 단축키 설정", + "hotkey.oneTouchTitle": "원터치 모드 단축키 설정", + "hotkey.captionTitle": "실시간 자막 단축키 설정", + "hotkey.prompt": "키 조합을 눌러주세요...", + "hotkey.ready": "✓ {{keys}} — 저장을 눌러주세요", + "hotkey.noKey": "키를 입력해주세요", + "hotkey.reserved": "{{keys}}은 시스템 예약 단축키입니다", + "hotkey.current": "현재: {{keys}}", + "hotkey.hint": "조합키(예: Ctrl+Shift+Q) 또는 단일키(예: F5)를 입력하세요", + "hotkey.reset": "다시 입력", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "STEP 1 — Ollama 설치", + "ollama.step1.desc": "Ollama는 로컬에서 LLM을 실행하는 무료 도구입니다.", + "ollama.step2.title": "STEP 2 — 모델 다운로드", + "ollama.step2.desc": "터미널에서 원하는 모델을 pull하세요. 한국어에 추천:", + "ollama.step2.alt": "또는 더 큰 모델: ollama pull qwen3:8b (더 정확, 더 느림)", + "ollama.step3.title": "STEP 3 — 자동 연결", + "ollama.step3.desc": "Ollama가 실행되면 D3RO-VOICE가 자동으로 감지합니다. 하단 상태 바의 LED가 빨간색에서 초록색으로 바뀌면 준비 완료!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + "common.cancel": "취소", "common.save": "저장", "common.delete": "삭제", "common.add": "추가", "common.edit": "편집", "common.close": "닫기", + "common.confirm": "확인", "common.loading": "로딩...", - "status.ollamaConnected": "Ollama 연결됨", - "status.ollamaOffline": "Ollama 오프라인" + "common.copy": "복사", + "common.test": "테스트", + "common.stop": "중지", + + "memo.tags": "태그", + "memo.addTag": "태그 추가", + "memo.removeTag": "태그 제거", + "memo.tagPlaceholder": "태그 입력 후 Enter", + "memo.noTags": "태그 없음", + "memo.export": "마크다운 내보내기", + "memo.exportSuccess": "내보내기 완료", + "memo.filterByTag": "태그로 필터", + "memo.allTags": "전체 태그", + "memo.clearFilter": "필터 해제", + + "voiceCommand.title": "음성 명령어", + "voiceCommand.enabled": "음성 키워드 인식", + "voiceCommand.enabledDesc": "전사 시작 시 키워드를 감지하여 명령어를 자동 선택합니다.", + "voiceCommand.keywords": "키워드", + "voiceCommand.keywordPlaceholder": "키워드 입력 후 Enter", + "voiceCommand.noKeywords": "키워드 없음", + "voiceCommand.matchMode": "매칭 모드", + "voiceCommand.matchMode.prefix": "앞부분 일치", + "voiceCommand.matchMode.suffix": "뒷부분 일치", + "voiceCommand.matchMode.contains": "포함", + + "chain.title": "LLM 체인", + "chain.count": "{{count}}개", + "chain.add": "체인 추가", + "chain.editTitle": "체인 편집", + "chain.addTitle": "체인 추가", + "chain.name": "체인 이름", + "chain.steps": "단계", + "chain.addStep": "단계 추가", + "chain.removeStep": "제거", + "chain.inputSource": "입력 소스", + "chain.inputSource.original": "원본 텍스트", + "chain.inputSource.previous": "이전 단계 결과", + "chain.selectInstruction": "명령어 선택", + "chain.execute": "실행", + "chain.executing": "실행 중...", + "chain.noChains": "체인 없음 — 여러 명령어를 순차 실행하는 파이프라인을 만들어 보세요", + "chain.step": "{{n}}단계", + "chain.progress": "{{current}}/{{total}} 단계 처리 중", + + "context.title": "화면 컨텍스트", + "context.enabled": "스크린 컨텍스트", + "context.enabledDesc": "녹음 시작 시 활성 앱과 선택된 텍스트를 캡처하여 LLM에 전달합니다.", + + "dashboard.caption": "실시간 자막", + "dashboard.captionStart": "자막 시작", + "dashboard.captionStop": "자막 종료", + "dashboard.captionActive": "자막 활성", + + "settings.voiceCommands": "음성 명령어", + "settings.voiceCommands.desc": "전사된 텍스트에서 키워드를 감지하여 명령어를 자동 선택합니다.", + "settings.screenContext": "화면 컨텍스트", + "settings.screenContext.desc": "녹음 시 활성 앱 정보와 선택된 텍스트를 LLM에 함께 전달합니다.", + + "date.today": "오늘", + "date.yesterday": "어제", + + "license.title": "라이선스", + "license.currentTier": "현재 등급", + "license.free": "FREE", + "license.pro": "PRO", + "license.proPlus": "PRO+", + "license.activate": "라이선스 활성화", + "license.deactivate": "비활성화", + "license.keyPlaceholder": "라이선스 키 입력...", + "license.activating": "활성화 중...", + "license.activated": "활성화 완료", + "license.activateError": "활성화 실패: {{message}}", + "license.deactivated": "비활성화 완료", + "license.machineId": "기기 ID", + "license.activatedAt": "활성화 일시", + "license.manageLicense": "라이선스 관리", + "license.upgrade": "업그레이드", + "license.upgradeTitle": "Pro로 업그레이드", + "license.upgradeDesc": "모든 기능을 잠금 해제하세요", + "license.quotaUsed": "{{used}}/{{limit}} 사용", + "license.quotaUnlimited": "무제한", + "license.tierComparison": "등급 비교", + "license.dailyUsage": "일일 사용량", + "license.feature.dictation": "받아쓰기", + "license.feature.llm_process": "LLM 처리", + "license.feature.live_caption": "실시간 자막", + "license.feature.screen_context": "화면 컨텍스트", + "license.feature.voice_command": "음성 명령어", + "license.feature.llm_chain": "LLM 체인", + "license.feature.voice_memo": "음성 메모", + "license.feature.history_unlimited": "무제한 히스토리", + "license.feature.history_export": "히스토리 내보내기", + "license.feature.custom_instruction_create": "커스텀 명령어 생성", + "license.feature.file_transcription": "파일 전사", + "license.feature.voice_conversation": "음성 대화", + "license.feature.dictation_template": "받아쓰기 템플릿", + "license.feature.meeting_summary": "회의 요약", + "license.feature.local_rag": "로컬 RAG", + "license.feature.os_automation": "OS 자동화", + "license.pro.required": "PRO 필요", + "license.proPlus.required": "PRO+ 필요", + "license.included": "포함", + "license.notIncluded": "미포함", + "license.keyLabel": "라이선스 키", + "license.quotaExceeded.title": "오늘의 {{feature}}을(를) 모두 사용했습니다", + "license.quotaExceeded.desc": "Pro로 업그레이드하면 무제한으로 사용할 수 있습니다", + "license.tierRequired.title": "{{feature}}은(는) {{tier}} 기능입니다", + "license.tierRequired.desc": "{{tier}}로 업그레이드하여 잠금을 해제하세요", + "license.tryTomorrow": "내일 다시 사용하기", + "license.learnMore": "알아보기", + "license.upgradeBenefits": "업그레이드 혜택", + "license.benefit.unlimitedDictation": "무제한 받아쓰기", + "license.benefit.unlimitedLLM": "무제한 AI 다듬기", + "license.benefit.liveCaption": "실시간 자막", + "license.benefit.unlimitedHistory": "히스토리 무제한 보존", + "license.usageToday": "오늘 사용량", + "license.perDay": "회/일", + "license.unlimited": "무제한", + "license.locked": "잠금", + "license.nav": "라이선스" } diff --git a/src/renderer/i18n/pt.json b/src/renderer/i18n/pt.json new file mode 100644 index 0000000..54fcadb --- /dev/null +++ b/src/renderer/i18n/pt.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "Assistente de voz com IA local", + + "nav.dashboard": "Painel", + "nav.history": "Histórico", + "nav.dictionary": "Dicionário", + "nav.commands": "Comandos", + "nav.settings": "Configurações", + + "dashboard.sessionOverview": "Visão geral da sessão", + "dashboard.systemStatus": "Status do sistema", + "dashboard.sessionsToday": "Sessões hoje", + "dashboard.pressToRecord": "Pressione {{key}} para iniciar a gravação", + "dashboard.hotkeyNotSet": "Atalho não configurado", + "dashboard.words": "palavras", + "dashboard.total": "total", + "dashboard.streak": "sequência", + "dashboard.days": "dias", + "dashboard.recording": "gravando", + "dashboard.sessions": "sessões", + "dashboard.today": "hoje", + "dashboard.recentTranscriptions": "Transcrições recentes", + "dashboard.noHistory": "Sem histórico — pressione o atalho para começar a gravar", + "dashboard.copy": "Copiar", + "dashboard.entries": "{{count}} entradas", + "dashboard.stat": "estatísticas", + "dashboard.sys": "sistema", + + "history.title": "Histórico de transcrições", + "history.search": "Pesquisar...", + "history.entries": "{{count}} entradas", + "history.loading": "Carregando...", + "history.noResults": "Sem resultados", + "history.noHistory": "Sem histórico — comece a gravar", + "history.count": "{{count}} entradas", + + "dictionary.title": "Dicionário personalizado", + "dictionary.words": "{{count}} palavras", + "dictionary.add": "Adicionar", + "dictionary.search": "Pesquisar...", + "dictionary.loading": "Carregando...", + "dictionary.noResults": "Sem resultados", + "dictionary.noWords": "Sem palavras — adicione palavras personalizadas para melhorar a precisão do STT", + "dictionary.used": "usado {{count}} vezes", + "dictionary.editTitle": "Editar palavra", + "dictionary.addTitle": "Adicionar palavra", + "dictionary.word": "Palavra", + "dictionary.pronunciation": "Pronúncia (opcional)", + + "commands.title": "Comandos LLM", + "commands.count": "{{count}} comandos", + "commands.add": "Adicionar", + "commands.activeCommand": "Comando ativo", + "commands.none": "Nenhum", + "commands.loading": "Carregando...", + "commands.noCommands": "Sem comandos — clique em adicionar para criar um", + "commands.editTitle": "Editar comando", + "commands.addTitle": "Adicionar comando", + "commands.name": "Nome", + "commands.description": "Descrição", + "commands.promptTemplate": "Modelo de prompt", + "commands.promptHelp": "{{text}} será substituído pelo texto transcrito", + "commands.defaultPrompt": "Por favor, melhore {{text}}.", + + "settings.title": "Configurações", + "settings.tabs.general": "Geral", + "settings.tabs.audio": "Áudio", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "Sobre", + + "settings.shortcuts": "Atalhos", + "settings.dictation": "Ditado", + "settings.dictation.desc": "Mantenha pressionado para falar. A transcrição começa ao soltar.", + "settings.agent": "Modo agente", + "settings.agent.descWithKey": "Clique duas vezes em {{key}} para entrar no modo agente.", + "settings.agent.descNoKey": "Configure primeiro o atalho de ditado.", + "settings.oneTouch": "Modo de um toque", + "settings.oneTouch.desc": "Pressione para iniciar, pressione novamente para parar. Requer um atalho separado.", + "settings.key": "Tecla", + "settings.notSet": "Não configurado", + "settings.enabled": "Ativado", + "settings.disabled": "Desativado", + + "settings.interface": "Interface", + "settings.theme": "Tema", + "settings.theme.system": "Sistema", + "settings.theme.light": "Claro", + "settings.theme.dark": "Escuro", + "settings.language": "Idioma", + + "settings.appBehavior": "Comportamento do aplicativo", + "settings.closeToTray": "Minimizar para a bandeja", + "settings.autoLaunch": "Iniciar com o sistema", + "settings.autoInsert": "Inserir texto automaticamente após a transcrição", + "settings.soundEffects": "Efeitos sonoros", + + "settings.microphone": "Microfone", + "settings.inputDevice": "Dispositivo de entrada", + "settings.deviceDefault": "(padrão)", + "settings.textInsert": "Inserção de texto", + "settings.insertMethod": "Método de inserção", + "settings.insertClipboard": "Área de transferência (Ctrl+V)", + "settings.insertKeyboard": "Digitação pelo teclado", + + "settings.whisperModel": "Modelo Whisper", + "settings.model.tiny": "tiny (39 MB, mais rápido)", + "settings.model.base": "base (74 MB, equilibrado)", + "settings.model.small": "small (244 MB, bom)", + "settings.model.medium": "medium (769 MB, muito bom)", + "settings.model.large": "large-v3 (1,5 GB, melhor)", + "settings.sttLanguage": "Idioma de reconhecimento", + "settings.sttLang.auto": "Detecção automática", + + "settings.ollamaServer": "Servidor Ollama", + "settings.ollamaUrl": "URL do servidor Ollama", + "settings.ollamaHint": "D3RO Voice conecta-se automaticamente quando o Ollama está em execução. Baixe modelos diretamente pelo Ollama (ex: ollama pull qwen3:4b).", + "settings.postProcess": "Pós-processamento de voz", + "settings.defaultAction": "Ação de pós-processamento padrão", + "settings.action.none": "Nenhuma (texto original sem alterações)", + "settings.action.refine": "Refinar (gramática + naturalidade)", + "settings.action.translate": "Traduzir", + "settings.action.summarize": "Resumir", + "settings.action.grammar": "Correção gramatical", + "settings.action.custom": "Prompt personalizado", + "settings.actionHint": "O pós-processamento LLM selecionado é aplicado ao texto transcrito após gravar com o atalho. Só funciona quando o Ollama está conectado.", + + "settings.about.version": "Versão", + "settings.about.techStack": "Stack tecnológico", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Motor de voz", + "settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)", + "settings.about.description": "Assistente de voz com IA totalmente local, baseado em engenharia reversa do Speakly. Funciona sem dependências de nuvem.", + "settings.about.restartOnboarding": "Ver guia de configuração inicial novamente", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama não está em execução", + "status.nudge.desc": "O Ollama é necessário para o pós-processamento LLM (tradução, resumo, etc.).", + "status.nudge.guide": "Ver guia de instalação →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "Sem digitar, por voz. Seu assistente de voz com IA local.", + "onboarding.welcome.start": "Começar", + "onboarding.mic.title": "Configurar microfone", + "onboarding.mic.desc": "Selecione o microfone que deseja usar. Você pode alterá-lo depois nas configurações.", + "onboarding.hotkey.title": "Configurar atalho", + "onboarding.hotkey.desc": "Configure o atalho de ditado. A gravação continua enquanto você mantiver a tecla pressionada.", + "onboarding.hotkey.notSet": "Nenhum atalho configurado", + "onboarding.hotkey.change": "Alterar atalho", + "onboarding.hotkey.set": "Configurar atalho", + "onboarding.ollama.title": "Instalar Ollama (opcional)", + "onboarding.ollama.desc": "O Ollama é necessário para pós-processamento LLM como tradução ou resumo. O ditado de voz funciona sem o Ollama.", + "onboarding.ollama.download": "Baixar Ollama", + "onboarding.ollama.modelHint": "Após instalar, baixe um modelo pelo terminal", + "onboarding.done.title": "Configuração concluída!", + "onboarding.done.descWithKey": "Mantenha {{key}} pressionado e fale para converter sua voz em texto.", + "onboarding.done.descNoKey": "Configure um atalho nas configurações para começar a ditar.", + "onboarding.done.start": "Começar", + "onboarding.back": "Voltar", + "onboarding.next": "Próximo", + + "hotkey.title": "Configurar atalho", + "hotkey.dictationTitle": "Configurar atalho de ditado", + "hotkey.oneTouchTitle": "Configurar atalho do modo de um toque", + "hotkey.prompt": "Pressione uma combinação de teclas...", + "hotkey.ready": "✓ {{keys}} — pressione salvar", + "hotkey.noKey": "Por favor, insira uma tecla", + "hotkey.reserved": "{{keys}} é um atalho reservado pelo sistema", + "hotkey.current": "Atual: {{keys}}", + "hotkey.hint": "Insira uma combinação (ex: Ctrl+Shift+Q) ou uma tecla única (ex: F5)", + "hotkey.reset": "Inserir novamente", + + "ollama.title": "GUIA DE CONFIGURAÇÃO DO OLLAMA", + "ollama.step1.title": "PASSO 1 — Instalar o Ollama", + "ollama.step1.desc": "O Ollama é uma ferramenta gratuita para executar LLMs localmente.", + "ollama.step2.title": "PASSO 2 — Baixar um modelo", + "ollama.step2.desc": "Execute o seguinte comando no terminal para baixar o modelo recomendado:", + "ollama.step2.alt": "Ou um modelo maior: ollama pull qwen3:8b (mais preciso, mais lento)", + "ollama.step3.title": "PASSO 3 — Conexão automática", + "ollama.step3.desc": "D3RO-VOICE detecta automaticamente quando o Ollama está em execução. Quando o LED da barra de status mudar de vermelho para verde, está pronto!", + + "service.sttEngine": "MOTOR STT", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "ATALHO DE TECLADO", + "service.audioInput": "ENTRADA DE ÁUDIO", + "service.ready": "PRONTO", + "service.connected": "CONECTADO", + "service.offline": "OFFLINE", + "service.active": "ATIVO", + "service.standby": "EM ESPERA", + + "common.cancel": "Cancelar", + "common.save": "Salvar", + "common.delete": "Excluir", + "common.add": "Adicionar", + "common.edit": "Editar", + "common.close": "Fechar", + "common.confirm": "Confirmar", + "common.loading": "Carregando...", + "common.copy": "Copiar", + "common.test": "Testar", + "common.stop": "Parar", + + "date.today": "Hoje", + "date.yesterday": "Ontem" +} diff --git a/src/renderer/i18n/ru.json b/src/renderer/i18n/ru.json new file mode 100644 index 0000000..a298e4f --- /dev/null +++ b/src/renderer/i18n/ru.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "Локальный голосовой ИИ-ассистент", + + "nav.dashboard": "Панель", + "nav.history": "История", + "nav.dictionary": "Словарь", + "nav.commands": "Команды", + "nav.settings": "Настройки", + + "dashboard.sessionOverview": "Обзор сессий", + "dashboard.systemStatus": "Состояние системы", + "dashboard.sessionsToday": "Сессий сегодня", + "dashboard.pressToRecord": "Нажмите {{key}} для начала записи", + "dashboard.hotkeyNotSet": "Горячая клавиша не задана", + "dashboard.words": "Слова", + "dashboard.total": "Всего", + "dashboard.streak": "Серия", + "dashboard.days": "дн.", + "dashboard.recording": "Запись", + "dashboard.sessions": "Сессии", + "dashboard.today": "Сегодня", + "dashboard.recentTranscriptions": "Последние транскрипции", + "dashboard.noHistory": "История пуста — нажмите горячую клавишу для начала записи", + "dashboard.copy": "Копировать", + "dashboard.entries": "{{count}} записей", + "dashboard.stat": "Статистика", + "dashboard.sys": "Система", + + "history.title": "История транскрипций", + "history.search": "Поиск...", + "history.entries": "{{count}} записей", + "history.loading": "Загрузка...", + "history.noResults": "Результаты не найдены", + "history.noHistory": "История пуста — начните запись", + "history.count": "{{count}} записей", + + "dictionary.title": "Пользовательский словарь", + "dictionary.words": "{{count}} слов", + "dictionary.add": "Добавить", + "dictionary.search": "Поиск...", + "dictionary.loading": "Загрузка...", + "dictionary.noResults": "Результаты не найдены", + "dictionary.noWords": "Слов нет — добавьте собственные слова для улучшения точности STT", + "dictionary.used": "Использовано {{count}} раз", + "dictionary.editTitle": "Редактировать слово", + "dictionary.addTitle": "Добавить слово", + "dictionary.word": "Слово", + "dictionary.pronunciation": "Произношение (необязательно)", + + "commands.title": "Команды LLM", + "commands.count": "{{count}} шт.", + "commands.add": "Добавить", + "commands.activeCommand": "Активная команда", + "commands.none": "Нет", + "commands.loading": "Загрузка...", + "commands.noCommands": "Команд нет — нажмите «Добавить» для создания", + "commands.editTitle": "Редактировать команду", + "commands.addTitle": "Добавить команду", + "commands.name": "Название", + "commands.description": "Описание", + "commands.promptTemplate": "Шаблон промпта", + "commands.promptHelp": "{{text}} будет заменено на транскрибированный текст", + "commands.defaultPrompt": "Пожалуйста, улучшите {{text}}.", + + "settings.title": "Настройки", + "settings.tabs.general": "Общие", + "settings.tabs.audio": "Аудио", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "О программе", + + "settings.shortcuts": "Горячие клавиши", + "settings.dictation": "Диктовка", + "settings.dictation.desc": "Говорите, удерживая клавишу. Отпустите для начала транскрипции.", + "settings.agent": "Режим агента", + "settings.agent.descWithKey": "Дважды нажмите {{key}} для перехода в режим агента.", + "settings.agent.descNoKey": "Сначала задайте горячую клавишу диктовки.", + "settings.oneTouch": "Режим одного нажатия", + "settings.oneTouch.desc": "Нажмите для начала, нажмите снова для остановки. Требуется отдельная горячая клавиша.", + "settings.key": "Клавиша", + "settings.notSet": "Не задано", + "settings.enabled": "Включено", + "settings.disabled": "Выключено", + + "settings.interface": "Интерфейс", + "settings.theme": "Тема", + "settings.theme.system": "Системная", + "settings.theme.light": "Светлая", + "settings.theme.dark": "Тёмная", + "settings.language": "Язык", + + "settings.appBehavior": "Поведение приложения", + "settings.closeToTray": "Сворачивать в трей", + "settings.autoLaunch": "Автозапуск при старте системы", + "settings.autoInsert": "Автоматически вставлять текст после транскрипции", + "settings.soundEffects": "Звуковые эффекты", + + "settings.microphone": "Микрофон", + "settings.inputDevice": "Устройство ввода", + "settings.deviceDefault": "(По умолчанию)", + "settings.textInsert": "Вставка текста", + "settings.insertMethod": "Метод вставки", + "settings.insertClipboard": "Буфер обмена (Ctrl+V)", + "settings.insertKeyboard": "Эмуляция клавиатуры", + + "settings.whisperModel": "Модель Whisper", + "settings.model.tiny": "tiny (39 МБ, самая быстрая)", + "settings.model.base": "base (74 МБ, баланс)", + "settings.model.small": "small (244 МБ, хорошая)", + "settings.model.medium": "medium (769 МБ, отличная)", + "settings.model.large": "large-v3 (1.5 ГБ, лучшая)", + "settings.sttLanguage": "Язык распознавания", + "settings.sttLang.auto": "Автоопределение", + + "settings.ollamaServer": "Сервер Ollama", + "settings.ollamaUrl": "URL сервера Ollama", + "settings.ollamaHint": "Если Ollama запущена, подключение произойдёт автоматически. Загружайте модели напрямую через Ollama (например: ollama pull qwen3:4b).", + "settings.postProcess": "Постобработка речи", + "settings.defaultAction": "Команда постобработки по умолчанию", + "settings.action.none": "Нет (оригинальный текст)", + "settings.action.refine": "Улучшить (грамматика + естественность)", + "settings.action.translate": "Перевести", + "settings.action.summarize": "Резюмировать", + "settings.action.grammar": "Исправить грамматику", + "settings.action.custom": "Пользовательский промпт", + "settings.actionHint": "После записи и транскрипции к тексту будет применена выбранная постобработка LLM. Работает только при подключённом Ollama.", + + "settings.about.version": "Версия", + "settings.about.techStack": "Технический стек", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Голосовой движок", + "settings.about.voiceEngineValue": "STT: faster-whisper (локально) / LLM: Ollama (локально)", + "settings.about.description": "Локальный голосовой ИИ-ассистент, основанный на методах обратной разработки Speakly. Работает полностью локально без зависимости от облачных сервисов.", + "settings.about.restartOnboarding": "Показать начальную настройку снова", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama не запущена", + "status.nudge.desc": "Для постобработки LLM (перевод, резюмирование и т.д.) требуется Ollama.", + "status.nudge.guide": "Смотреть инструкцию по установке →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "Без набора текста — только голос. Локальный голосовой ИИ-ассистент.", + "onboarding.welcome.start": "Начать", + "onboarding.mic.title": "Настройка микрофона", + "onboarding.mic.desc": "Выберите микрофон. Его можно изменить позже в настройках.", + "onboarding.hotkey.title": "Настройка горячей клавиши", + "onboarding.hotkey.desc": "Задайте горячую клавишу для диктовки. Удерживайте её для записи.", + "onboarding.hotkey.notSet": "Горячая клавиша не задана", + "onboarding.hotkey.change": "Изменить горячую клавишу", + "onboarding.hotkey.set": "Задать горячую клавишу", + "onboarding.ollama.title": "Установка Ollama (необязательно)", + "onboarding.ollama.desc": "Для постобработки LLM (перевод, резюмирование и т.д.) требуется Ollama. Голосовая диктовка работает и без неё.", + "onboarding.ollama.download": "Скачать Ollama", + "onboarding.ollama.modelHint": "После установки загрузите модель через терминал", + "onboarding.done.title": "Настройка завершена!", + "onboarding.done.descWithKey": "Удерживайте {{key}} и говорите — речь будет преобразована в текст.", + "onboarding.done.descNoKey": "Задайте горячую клавишу в настройках, чтобы начать голосовую диктовку.", + "onboarding.done.start": "Начать", + "onboarding.back": "Назад", + "onboarding.next": "Далее", + + "hotkey.title": "Настройка горячей клавиши", + "hotkey.dictationTitle": "Задать горячую клавишу диктовки", + "hotkey.oneTouchTitle": "Задать горячую клавишу режима одного нажатия", + "hotkey.prompt": "Нажмите сочетание клавиш...", + "hotkey.ready": "✓ {{keys}} — нажмите «Сохранить»", + "hotkey.noKey": "Введите клавишу", + "hotkey.reserved": "{{keys}} является зарезервированным сочетанием системы", + "hotkey.current": "Текущее: {{keys}}", + "hotkey.hint": "Введите сочетание клавиш (например: Ctrl+Shift+Q) или одну клавишу (например: F5)", + "hotkey.reset": "Ввести снова", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "ШАГ 1 — Установка Ollama", + "ollama.step1.desc": "Ollama — бесплатный инструмент для локального запуска LLM.", + "ollama.step2.title": "ШАГ 2 — Загрузка модели", + "ollama.step2.desc": "Загрузите нужную модель через терминал. Рекомендуется:", + "ollama.step2.alt": "Или более крупная модель: ollama pull qwen3:8b (точнее, медленнее)", + "ollama.step3.title": "ШАГ 3 — Автоподключение", + "ollama.step3.desc": "При запуске Ollama D3RO-VOICE обнаружит её автоматически. Когда индикатор LED на нижней панели сменится с красного на зелёный — готово!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + + "common.cancel": "Отмена", + "common.save": "Сохранить", + "common.delete": "Удалить", + "common.add": "Добавить", + "common.edit": "Изменить", + "common.close": "Закрыть", + "common.confirm": "Подтвердить", + "common.loading": "Загрузка...", + "common.copy": "Копировать", + "common.test": "Тест", + "common.stop": "Стоп", + + "date.today": "Сегодня", + "date.yesterday": "Вчера" +} diff --git a/src/renderer/i18n/th.json b/src/renderer/i18n/th.json new file mode 100644 index 0000000..99a4b60 --- /dev/null +++ b/src/renderer/i18n/th.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "ผู้ช่วยเสียง AI ในเครื่อง", + + "nav.dashboard": "แดชบอร์ด", + "nav.history": "ประวัติ", + "nav.dictionary": "พจนานุกรม", + "nav.commands": "คำสั่ง", + "nav.settings": "การตั้งค่า", + + "dashboard.sessionOverview": "ภาพรวมเซสชัน", + "dashboard.systemStatus": "สถานะระบบ", + "dashboard.sessionsToday": "เซสชันวันนี้", + "dashboard.pressToRecord": "กด {{key}} เพื่อเริ่มบันทึก", + "dashboard.hotkeyNotSet": "ยังไม่ได้ตั้งค่าปุ่มลัด", + "dashboard.words": "คำ", + "dashboard.total": "ทั้งหมด", + "dashboard.streak": "ต่อเนื่อง", + "dashboard.days": "วัน", + "dashboard.recording": "กำลังบันทึก", + "dashboard.sessions": "เซสชัน", + "dashboard.today": "วันนี้", + "dashboard.recentTranscriptions": "การถอดความล่าสุด", + "dashboard.noHistory": "ไม่มีประวัติ — กดปุ่มลัดเพื่อเริ่มบันทึก", + "dashboard.copy": "คัดลอก", + "dashboard.entries": "{{count}} รายการ", + "dashboard.stat": "สถิติ", + "dashboard.sys": "ระบบ", + + "history.title": "ประวัติการถอดความ", + "history.search": "ค้นหา...", + "history.entries": "{{count}} รายการ", + "history.loading": "กำลังโหลด...", + "history.noResults": "ไม่พบผลลัพธ์", + "history.noHistory": "ไม่มีประวัติ — เริ่มบันทึกเสียง", + "history.count": "{{count}} รายการ", + + "dictionary.title": "พจนานุกรมที่กำหนดเอง", + "dictionary.words": "{{count}} คำ", + "dictionary.add": "เพิ่ม", + "dictionary.search": "ค้นหา...", + "dictionary.loading": "กำลังโหลด...", + "dictionary.noResults": "ไม่พบผลลัพธ์", + "dictionary.noWords": "ไม่มีคำ — เพิ่มคำที่กำหนดเองเพื่อเพิ่มความแม่นยำของ STT", + "dictionary.used": "ใช้แล้ว {{count}} ครั้ง", + "dictionary.editTitle": "แก้ไขคำ", + "dictionary.addTitle": "เพิ่มคำ", + "dictionary.word": "คำ", + "dictionary.pronunciation": "การออกเสียง (ไม่บังคับ)", + + "commands.title": "คำสั่ง LLM", + "commands.count": "{{count}} รายการ", + "commands.add": "เพิ่ม", + "commands.activeCommand": "คำสั่งที่ใช้งานอยู่", + "commands.none": "ไม่มี", + "commands.loading": "กำลังโหลด...", + "commands.noCommands": "ไม่มีคำสั่ง — คลิกเพิ่มเพื่อสร้าง", + "commands.editTitle": "แก้ไขคำสั่ง", + "commands.addTitle": "เพิ่มคำสั่ง", + "commands.name": "ชื่อ", + "commands.description": "คำอธิบาย", + "commands.promptTemplate": "เทมเพลตพรอมต์", + "commands.promptHelp": "{{text}} จะถูกแทนที่ด้วยข้อความที่ถอดความ", + "commands.defaultPrompt": "กรุณาปรับปรุง {{text}}", + + "settings.title": "การตั้งค่า", + "settings.tabs.general": "ทั่วไป", + "settings.tabs.audio": "เสียง", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "เกี่ยวกับ", + + "settings.shortcuts": "ปุ่มลัด", + "settings.dictation": "การบอกเล่า", + "settings.dictation.desc": "กดค้างไว้แล้วพูด เมื่อปล่อยจะเริ่มถอดความ", + "settings.agent": "โหมด Agent", + "settings.agent.descWithKey": "กด {{key}} สองครั้งเพื่อเข้าสู่โหมด Agent", + "settings.agent.descNoKey": "กรุณาตั้งค่าปุ่มลัดการบอกเล่าก่อน", + "settings.oneTouch": "โหมดสัมผัสเดียว", + "settings.oneTouch.desc": "กดเพื่อเริ่ม กดอีกครั้งเพื่อหยุด ต้องใช้ปุ่มลัดแยกต่างหาก", + "settings.key": "ปุ่ม", + "settings.notSet": "ยังไม่ได้ตั้งค่า", + "settings.enabled": "เปิดใช้งาน", + "settings.disabled": "ปิดใช้งาน", + + "settings.interface": "อินเทอร์เฟซ", + "settings.theme": "ธีม", + "settings.theme.system": "ระบบ", + "settings.theme.light": "สว่าง", + "settings.theme.dark": "มืด", + "settings.language": "ภาษา", + + "settings.appBehavior": "พฤติกรรมแอป", + "settings.closeToTray": "ย่อลงถาดระบบ", + "settings.autoLaunch": "เปิดอัตโนมัติเมื่อเริ่มระบบ", + "settings.autoInsert": "แทรกข้อความอัตโนมัติหลังถอดความ", + "settings.soundEffects": "เอฟเฟกต์เสียง", + + "settings.microphone": "ไมโครโฟน", + "settings.inputDevice": "อุปกรณ์อินพุต", + "settings.deviceDefault": "(ค่าเริ่มต้น)", + "settings.textInsert": "การแทรกข้อความ", + "settings.insertMethod": "วิธีการแทรก", + "settings.insertClipboard": "คลิปบอร์ด (Ctrl+V)", + "settings.insertKeyboard": "จำลองการพิมพ์", + + "settings.whisperModel": "โมเดล Whisper", + "settings.model.tiny": "tiny (39 MB, เร็วที่สุด)", + "settings.model.base": "base (74 MB, สมดุล)", + "settings.model.small": "small (244 MB, ดี)", + "settings.model.medium": "medium (769 MB, ดีเยี่ยม)", + "settings.model.large": "large-v3 (1.5 GB, ดีที่สุด)", + "settings.sttLanguage": "ภาษาที่รู้จัก", + "settings.sttLang.auto": "ตรวจจับอัตโนมัติ", + + "settings.ollamaServer": "เซิร์ฟเวอร์ Ollama", + "settings.ollamaUrl": "URL เซิร์ฟเวอร์ Ollama", + "settings.ollamaHint": "หาก Ollama กำลังทำงาน จะเชื่อมต่ออัตโนมัติ ดาวน์โหลดโมเดลโดยตรงจาก Ollama (เช่น: ollama pull qwen3:4b)", + "settings.postProcess": "การประมวลผลเสียงหลังการบันทึก", + "settings.defaultAction": "คำสั่งประมวลผลเริ่มต้น", + "settings.action.none": "ไม่มี (ข้อความต้นฉบับ)", + "settings.action.refine": "ปรับปรุง (ไวยากรณ์ + ความเป็นธรรมชาติ)", + "settings.action.translate": "แปล", + "settings.action.summarize": "สรุป", + "settings.action.grammar": "แก้ไขไวยากรณ์", + "settings.action.custom": "พรอมต์ที่กำหนดเอง", + "settings.actionHint": "การประมวลผล LLM ที่เลือกจะถูกนำไปใช้กับข้อความที่ถอดความหลังจากบันทึก ทำงานเฉพาะเมื่อเชื่อมต่อ Ollama", + + "settings.about.version": "เวอร์ชัน", + "settings.about.techStack": "เทคโนโลยีที่ใช้", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "เอนจิ้นเสียง", + "settings.about.voiceEngineValue": "STT: faster-whisper (ในเครื่อง) / LLM: Ollama (ในเครื่อง)", + "settings.about.description": "ผู้ช่วยเสียง AI ในเครื่องที่พัฒนาจากความรู้การวิศวกรรมย้อนกลับ Speakly ทำงานได้อย่างสมบูรณ์ในเครื่องโดยไม่พึ่งพาบริการคลาวด์", + "settings.about.restartOnboarding": "ดูคู่มือการตั้งค่าเริ่มต้นอีกครั้ง", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama ยังไม่ได้ทำงาน", + "status.nudge.desc": "ต้องใช้ Ollama สำหรับการประมวลผล LLM (แปล สรุป ฯลฯ)", + "status.nudge.guide": "ดูคำแนะนำการติดตั้ง →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "ไม่ต้องพิมพ์ — แค่พูด ผู้ช่วยเสียง AI ในเครื่อง", + "onboarding.welcome.start": "เริ่มต้น", + "onboarding.mic.title": "การตั้งค่าไมโครโฟน", + "onboarding.mic.desc": "เลือกไมโครโฟนที่ต้องการใช้ สามารถเปลี่ยนได้ในการตั้งค่า", + "onboarding.hotkey.title": "การตั้งค่าปุ่มลัด", + "onboarding.hotkey.desc": "ตั้งค่าปุ่มลัดสำหรับการบอกเล่า กดค้างไว้ขณะบันทึก", + "onboarding.hotkey.notSet": "ยังไม่ได้ตั้งค่าปุ่มลัด", + "onboarding.hotkey.change": "เปลี่ยนปุ่มลัด", + "onboarding.hotkey.set": "ตั้งค่าปุ่มลัด", + "onboarding.ollama.title": "ติดตั้ง Ollama (ไม่บังคับ)", + "onboarding.ollama.desc": "ต้องใช้ Ollama สำหรับการประมวลผล LLM (แปล สรุป ฯลฯ) การบอกเล่าเสียงทำงานได้โดยไม่มี Ollama", + "onboarding.ollama.download": "ดาวน์โหลด Ollama", + "onboarding.ollama.modelHint": "หลังติดตั้ง ดาวน์โหลดโมเดลผ่านเทอร์มินัล", + "onboarding.done.title": "ตั้งค่าเสร็จสมบูรณ์!", + "onboarding.done.descWithKey": "กด {{key}} ค้างไว้แล้วพูด — เสียงจะถูกแปลงเป็นข้อความ", + "onboarding.done.descNoKey": "ตั้งค่าปุ่มลัดในการตั้งค่าเพื่อเริ่มการบอกเล่าเสียง", + "onboarding.done.start": "เริ่มต้น", + "onboarding.back": "ย้อนกลับ", + "onboarding.next": "ถัดไป", + + "hotkey.title": "การตั้งค่าปุ่มลัด", + "hotkey.dictationTitle": "ตั้งค่าปุ่มลัดการบอกเล่า", + "hotkey.oneTouchTitle": "ตั้งค่าปุ่มลัดโหมดสัมผัสเดียว", + "hotkey.prompt": "กดปุ่มที่ต้องการ...", + "hotkey.ready": "✓ {{keys}} — กดบันทึก", + "hotkey.noKey": "กรุณากรอกปุ่ม", + "hotkey.reserved": "{{keys}} เป็นปุ่มลัดที่ระบบสงวนไว้", + "hotkey.current": "ปัจจุบัน: {{keys}}", + "hotkey.hint": "กรอกปุ่มลัด (เช่น: Ctrl+Shift+Q) หรือปุ่มเดี่ยว (เช่น: F5)", + "hotkey.reset": "กรอกใหม่", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "ขั้นตอนที่ 1 — ติดตั้ง Ollama", + "ollama.step1.desc": "Ollama เป็นเครื่องมือฟรีสำหรับรัน LLM ในเครื่อง", + "ollama.step2.title": "ขั้นตอนที่ 2 — ดาวน์โหลดโมเดล", + "ollama.step2.desc": "ดาวน์โหลดโมเดลที่ต้องการผ่านเทอร์มินัล แนะนำ:", + "ollama.step2.alt": "หรือโมเดลที่ใหญ่กว่า: ollama pull qwen3:8b (แม่นยำกว่า ช้ากว่า)", + "ollama.step3.title": "ขั้นตอนที่ 3 — เชื่อมต่ออัตโนมัติ", + "ollama.step3.desc": "เมื่อ Ollama ทำงาน D3RO-VOICE จะตรวจพบโดยอัตโนมัติ เมื่อไฟ LED บนแถบสถานะเปลี่ยนจากสีแดงเป็นสีเขียว — พร้อมใช้งาน!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + + "common.cancel": "ยกเลิก", + "common.save": "บันทึก", + "common.delete": "ลบ", + "common.add": "เพิ่ม", + "common.edit": "แก้ไข", + "common.close": "ปิด", + "common.confirm": "ยืนยัน", + "common.loading": "กำลังโหลด...", + "common.copy": "คัดลอก", + "common.test": "ทดสอบ", + "common.stop": "หยุด", + + "date.today": "วันนี้", + "date.yesterday": "เมื่อวาน" +} diff --git a/src/renderer/i18n/vi.json b/src/renderer/i18n/vi.json new file mode 100644 index 0000000..0220c7d --- /dev/null +++ b/src/renderer/i18n/vi.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "Trợ lý giọng nói AI cục bộ", + + "nav.dashboard": "Bảng điều khiển", + "nav.history": "Lịch sử", + "nav.dictionary": "Từ điển", + "nav.commands": "Lệnh", + "nav.settings": "Cài đặt", + + "dashboard.sessionOverview": "Tổng quan phiên", + "dashboard.systemStatus": "Trạng thái hệ thống", + "dashboard.sessionsToday": "Phiên hôm nay", + "dashboard.pressToRecord": "Nhấn {{key}} để bắt đầu ghi âm", + "dashboard.hotkeyNotSet": "Chưa đặt phím tắt", + "dashboard.words": "Từ", + "dashboard.total": "Tổng", + "dashboard.streak": "Chuỗi", + "dashboard.days": "ngày", + "dashboard.recording": "Đang ghi", + "dashboard.sessions": "Phiên", + "dashboard.today": "Hôm nay", + "dashboard.recentTranscriptions": "Phiên âm gần đây", + "dashboard.noHistory": "Chưa có lịch sử — nhấn phím tắt để bắt đầu ghi âm", + "dashboard.copy": "Sao chép", + "dashboard.entries": "{{count}} mục", + "dashboard.stat": "Thống kê", + "dashboard.sys": "Hệ thống", + + "history.title": "Lịch sử phiên âm", + "history.search": "Tìm kiếm...", + "history.entries": "{{count}} mục", + "history.loading": "Đang tải...", + "history.noResults": "Không tìm thấy kết quả", + "history.noHistory": "Chưa có lịch sử — hãy bắt đầu ghi âm", + "history.count": "{{count}} mục", + + "dictionary.title": "Từ điển tùy chỉnh", + "dictionary.words": "{{count}} từ", + "dictionary.add": "Thêm", + "dictionary.search": "Tìm kiếm...", + "dictionary.loading": "Đang tải...", + "dictionary.noResults": "Không tìm thấy kết quả", + "dictionary.noWords": "Chưa có từ — thêm từ tùy chỉnh để cải thiện độ chính xác STT", + "dictionary.used": "Đã dùng {{count}} lần", + "dictionary.editTitle": "Chỉnh sửa từ", + "dictionary.addTitle": "Thêm từ", + "dictionary.word": "Từ", + "dictionary.pronunciation": "Phát âm (tùy chọn)", + + "commands.title": "Lệnh LLM", + "commands.count": "{{count}} lệnh", + "commands.add": "Thêm", + "commands.activeCommand": "Lệnh đang hoạt động", + "commands.none": "Không có", + "commands.loading": "Đang tải...", + "commands.noCommands": "Chưa có lệnh — nhấn Thêm để tạo", + "commands.editTitle": "Chỉnh sửa lệnh", + "commands.addTitle": "Thêm lệnh", + "commands.name": "Tên", + "commands.description": "Mô tả", + "commands.promptTemplate": "Mẫu prompt", + "commands.promptHelp": "{{text}} sẽ được thay thế bằng văn bản phiên âm", + "commands.defaultPrompt": "Vui lòng cải thiện {{text}}.", + + "settings.title": "Cài đặt", + "settings.tabs.general": "Chung", + "settings.tabs.audio": "Âm thanh", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "Giới thiệu", + + "settings.shortcuts": "Phím tắt", + "settings.dictation": "Chính tả", + "settings.dictation.desc": "Giữ phím và nói. Thả phím để bắt đầu phiên âm.", + "settings.agent": "Chế độ Agent", + "settings.agent.descWithKey": "Nhấn đúp {{key}} để vào chế độ Agent.", + "settings.agent.descNoKey": "Vui lòng đặt phím tắt chính tả trước.", + "settings.oneTouch": "Chế độ một chạm", + "settings.oneTouch.desc": "Nhấn để bắt đầu, nhấn lại để dừng. Cần phím tắt riêng.", + "settings.key": "Phím", + "settings.notSet": "Chưa đặt", + "settings.enabled": "Đã bật", + "settings.disabled": "Đã tắt", + + "settings.interface": "Giao diện", + "settings.theme": "Giao diện", + "settings.theme.system": "Hệ thống", + "settings.theme.light": "Sáng", + "settings.theme.dark": "Tối", + "settings.language": "Ngôn ngữ", + + "settings.appBehavior": "Hành vi ứng dụng", + "settings.closeToTray": "Thu nhỏ xuống khay hệ thống", + "settings.autoLaunch": "Tự động khởi động cùng hệ thống", + "settings.autoInsert": "Tự động chèn văn bản sau khi phiên âm", + "settings.soundEffects": "Hiệu ứng âm thanh", + + "settings.microphone": "Micrô", + "settings.inputDevice": "Thiết bị đầu vào", + "settings.deviceDefault": "(Mặc định)", + "settings.textInsert": "Chèn văn bản", + "settings.insertMethod": "Phương thức chèn", + "settings.insertClipboard": "Bộ nhớ tạm (Ctrl+V)", + "settings.insertKeyboard": "Giả lập bàn phím", + + "settings.whisperModel": "Mô hình Whisper", + "settings.model.tiny": "tiny (39 MB, nhanh nhất)", + "settings.model.base": "base (74 MB, cân bằng)", + "settings.model.small": "small (244 MB, tốt)", + "settings.model.medium": "medium (769 MB, xuất sắc)", + "settings.model.large": "large-v3 (1.5 GB, tốt nhất)", + "settings.sttLanguage": "Ngôn ngữ nhận dạng", + "settings.sttLang.auto": "Tự động phát hiện", + + "settings.ollamaServer": "Máy chủ Ollama", + "settings.ollamaUrl": "URL máy chủ Ollama", + "settings.ollamaHint": "Nếu Ollama đang chạy, kết nối sẽ tự động. Tải mô hình trực tiếp từ Ollama (ví dụ: ollama pull qwen3:4b).", + "settings.postProcess": "Hậu xử lý giọng nói", + "settings.defaultAction": "Lệnh hậu xử lý mặc định", + "settings.action.none": "Không có (văn bản gốc)", + "settings.action.refine": "Cải thiện (ngữ pháp + tự nhiên)", + "settings.action.translate": "Dịch", + "settings.action.summarize": "Tóm tắt", + "settings.action.grammar": "Sửa ngữ pháp", + "settings.action.custom": "Prompt tùy chỉnh", + "settings.actionHint": "Hậu xử lý LLM đã chọn sẽ được áp dụng cho văn bản phiên âm sau khi ghi âm. Chỉ hoạt động khi Ollama được kết nối.", + + "settings.about.version": "Phiên bản", + "settings.about.techStack": "Ngăn xếp công nghệ", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "Bộ máy giọng nói", + "settings.about.voiceEngineValue": "STT: faster-whisper (cục bộ) / LLM: Ollama (cục bộ)", + "settings.about.description": "Trợ lý giọng nói AI cục bộ dựa trên kỹ thuật dịch ngược Speakly. Hoạt động hoàn toàn cục bộ, không phụ thuộc vào dịch vụ đám mây.", + "settings.about.restartOnboarding": "Xem lại hướng dẫn cài đặt ban đầu", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama chưa chạy", + "status.nudge.desc": "Cần Ollama để sử dụng hậu xử lý LLM (dịch, tóm tắt, v.v.).", + "status.nudge.guide": "Xem hướng dẫn cài đặt →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "Không cần gõ phím — chỉ cần nói. Trợ lý giọng nói AI cục bộ.", + "onboarding.welcome.start": "Bắt đầu", + "onboarding.mic.title": "Cài đặt micrô", + "onboarding.mic.desc": "Chọn micrô bạn muốn dùng. Có thể thay đổi sau trong cài đặt.", + "onboarding.hotkey.title": "Cài đặt phím tắt", + "onboarding.hotkey.desc": "Đặt phím tắt chính tả. Giữ phím trong khi ghi âm.", + "onboarding.hotkey.notSet": "Phím tắt chưa được đặt", + "onboarding.hotkey.change": "Thay đổi phím tắt", + "onboarding.hotkey.set": "Đặt phím tắt", + "onboarding.ollama.title": "Cài đặt Ollama (tùy chọn)", + "onboarding.ollama.desc": "Cần Ollama để sử dụng hậu xử lý LLM (dịch, tóm tắt, v.v.). Chính tả giọng nói vẫn hoạt động mà không cần Ollama.", + "onboarding.ollama.download": "Tải Ollama", + "onboarding.ollama.modelHint": "Sau khi cài đặt, tải mô hình qua terminal", + "onboarding.done.title": "Thiết lập hoàn tất!", + "onboarding.done.descWithKey": "Giữ phím {{key}} và nói — giọng nói sẽ được chuyển thành văn bản.", + "onboarding.done.descNoKey": "Đặt phím tắt trong cài đặt để bắt đầu chính tả giọng nói.", + "onboarding.done.start": "Bắt đầu", + "onboarding.back": "Quay lại", + "onboarding.next": "Tiếp theo", + + "hotkey.title": "Cài đặt phím tắt", + "hotkey.dictationTitle": "Đặt phím tắt chính tả", + "hotkey.oneTouchTitle": "Đặt phím tắt chế độ một chạm", + "hotkey.prompt": "Nhấn tổ hợp phím...", + "hotkey.ready": "✓ {{keys}} — hãy nhấn Lưu", + "hotkey.noKey": "Vui lòng nhập phím", + "hotkey.reserved": "{{keys}} là phím tắt hệ thống được dành riêng", + "hotkey.current": "Hiện tại: {{keys}}", + "hotkey.hint": "Nhập tổ hợp phím (ví dụ: Ctrl+Shift+Q) hoặc phím đơn (ví dụ: F5)", + "hotkey.reset": "Nhập lại", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "BƯỚC 1 — Cài đặt Ollama", + "ollama.step1.desc": "Ollama là công cụ miễn phí để chạy LLM cục bộ.", + "ollama.step2.title": "BƯỚC 2 — Tải mô hình", + "ollama.step2.desc": "Tải mô hình bạn muốn qua terminal. Khuyến nghị:", + "ollama.step2.alt": "Hoặc mô hình lớn hơn: ollama pull qwen3:8b (chính xác hơn, chậm hơn)", + "ollama.step3.title": "BƯỚC 3 — Tự động kết nối", + "ollama.step3.desc": "Khi Ollama chạy, D3RO-VOICE sẽ tự động phát hiện. Khi đèn LED trên thanh trạng thái chuyển từ đỏ sang xanh — đã sẵn sàng!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + + "common.cancel": "Hủy", + "common.save": "Lưu", + "common.delete": "Xóa", + "common.add": "Thêm", + "common.edit": "Chỉnh sửa", + "common.close": "Đóng", + "common.confirm": "Xác nhận", + "common.loading": "Đang tải...", + "common.copy": "Sao chép", + "common.test": "Kiểm tra", + "common.stop": "Dừng", + + "date.today": "Hôm nay", + "date.yesterday": "Hôm qua" +} diff --git a/src/renderer/i18n/zh-TW.json b/src/renderer/i18n/zh-TW.json new file mode 100644 index 0000000..48036a0 --- /dev/null +++ b/src/renderer/i18n/zh-TW.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "本地AI語音助手", + + "nav.dashboard": "儀表板", + "nav.history": "歷史記錄", + "nav.dictionary": "辭典", + "nav.commands": "指令", + "nav.settings": "設定", + + "dashboard.sessionOverview": "工作階段總覽", + "dashboard.systemStatus": "系統狀態", + "dashboard.sessionsToday": "今日工作階段", + "dashboard.pressToRecord": "按下 {{key}} 開始錄音", + "dashboard.hotkeyNotSet": "尚未設定熱鍵", + "dashboard.words": "字數", + "dashboard.total": "總計", + "dashboard.streak": "連續", + "dashboard.days": "天", + "dashboard.recording": "錄音", + "dashboard.sessions": "工作階段", + "dashboard.today": "今天", + "dashboard.recentTranscriptions": "最近轉錄", + "dashboard.noHistory": "尚無歷史記錄 — 按下熱鍵開始錄音", + "dashboard.copy": "複製", + "dashboard.entries": "{{count}}筆", + "dashboard.stat": "統計", + "dashboard.sys": "系統", + + "history.title": "轉錄歷史", + "history.search": "搜尋...", + "history.entries": "{{count}}筆", + "history.loading": "載入中...", + "history.noResults": "無搜尋結果", + "history.noHistory": "尚無歷史記錄 — 請開始錄音", + "history.count": "{{count}}筆", + + "dictionary.title": "自訂辭典", + "dictionary.words": "{{count}}個詞", + "dictionary.add": "新增", + "dictionary.search": "搜尋...", + "dictionary.loading": "載入中...", + "dictionary.noResults": "無搜尋結果", + "dictionary.noWords": "尚無詞條 — 新增自訂詞彙以提高STT識別精度", + "dictionary.used": "已使用{{count}}次", + "dictionary.editTitle": "編輯詞條", + "dictionary.addTitle": "新增詞條", + "dictionary.word": "詞彙", + "dictionary.pronunciation": "發音(選填)", + + "commands.title": "LLM指令", + "commands.count": "{{count}}個", + "commands.add": "新增", + "commands.activeCommand": "目前指令", + "commands.none": "無", + "commands.loading": "載入中...", + "commands.noCommands": "尚無指令 — 點擊新增以建立", + "commands.editTitle": "編輯指令", + "commands.addTitle": "新增指令", + "commands.name": "名稱", + "commands.description": "說明", + "commands.promptTemplate": "提示詞範本", + "commands.promptHelp": "{{text}} 將被替換為轉錄的文字", + "commands.defaultPrompt": "請潤飾以下內容:{{text}}", + + "settings.title": "設定", + "settings.tabs.general": "一般", + "settings.tabs.audio": "音訊", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "關於", + + "settings.shortcuts": "快速鍵", + "settings.dictation": "聽寫", + "settings.dictation.desc": "按住鍵開始說話,放開鍵後開始轉錄。", + "settings.agent": "Agent模式", + "settings.agent.descWithKey": "連按兩下 {{key}} 進入Agent模式。", + "settings.agent.descNoKey": "請先設定聽寫熱鍵。", + "settings.oneTouch": "一鍵模式", + "settings.oneTouch.desc": "按下開始,再次按下停止。需要獨立的快速鍵。", + "settings.key": "按鍵", + "settings.notSet": "未設定", + "settings.enabled": "已啟用", + "settings.disabled": "已停用", + + "settings.interface": "介面", + "settings.theme": "主題", + "settings.theme.system": "跟隨系統", + "settings.theme.light": "淺色", + "settings.theme.dark": "深色", + "settings.language": "語言", + + "settings.appBehavior": "應用程式行為", + "settings.closeToTray": "最小化至系統匣", + "settings.autoLaunch": "開機時自動啟動", + "settings.autoInsert": "轉錄後自動插入文字", + "settings.soundEffects": "音效", + + "settings.microphone": "麥克風", + "settings.inputDevice": "輸入裝置", + "settings.deviceDefault": "(預設)", + "settings.textInsert": "文字插入", + "settings.insertMethod": "插入方式", + "settings.insertClipboard": "剪貼簿(Ctrl+V)", + "settings.insertKeyboard": "鍵盤輸入", + + "settings.whisperModel": "Whisper模型", + "settings.model.tiny": "tiny(39 MB,最快)", + "settings.model.base": "base(74 MB,均衡)", + "settings.model.small": "small(244 MB,良好)", + "settings.model.medium": "medium(769 MB,優秀)", + "settings.model.large": "large-v3(1.5 GB,最佳)", + "settings.sttLanguage": "識別語言", + "settings.sttLang.auto": "自動偵測", + + "settings.ollamaServer": "Ollama伺服器", + "settings.ollamaUrl": "Ollama伺服器URL", + "settings.ollamaHint": "Ollama執行時將自動連線。請直接在Ollama中pull模型(例如:ollama pull qwen3:4b)。", + "settings.postProcess": "語音後處理", + "settings.defaultAction": "預設後處理指令", + "settings.action.none": "無(保留原始文字)", + "settings.action.refine": "潤飾(語法+流暢度)", + "settings.action.translate": "翻譯", + "settings.action.summarize": "摘要", + "settings.action.grammar": "語法校正", + "settings.action.custom": "自訂提示詞", + "settings.actionHint": "透過熱鍵錄音後,所選LLM後處理將套用至轉錄文字。僅在Ollama連線時有效。", + + "settings.about.version": "版本", + "settings.about.techStack": "技術堆疊", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "語音引擎", + "settings.about.voiceEngineValue": "STT: faster-whisper(本地)/ LLM: Ollama(本地)", + "settings.about.description": "基於Speakly逆向工程經驗打造的本地AI語音助手,無需依賴雲端,完全在本地端執行。", + "settings.about.restartOnboarding": "重新檢視初始設定引導", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama尚未執行", + "status.nudge.desc": "使用LLM後處理(翻譯、摘要等)需要Ollama。", + "status.nudge.guide": "查看安裝指南 →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "無需打字,用聲音操作。本地AI語音助手。", + "onboarding.welcome.start": "開始", + "onboarding.mic.title": "麥克風設定", + "onboarding.mic.desc": "選擇要使用的麥克風,之後可在設定中更改。", + "onboarding.hotkey.title": "快速鍵設定", + "onboarding.hotkey.desc": "設定聽寫快速鍵,按住該鍵時進行錄音。", + "onboarding.hotkey.notSet": "尚未設定快速鍵", + "onboarding.hotkey.change": "變更快速鍵", + "onboarding.hotkey.set": "設定快速鍵", + "onboarding.ollama.title": "安裝Ollama(選填)", + "onboarding.ollama.desc": "使用翻譯、摘要等LLM後處理功能需要Ollama。語音聽寫本身無需Ollama即可使用。", + "onboarding.ollama.download": "下載Ollama", + "onboarding.ollama.modelHint": "安裝後在終端機中下載模型", + "onboarding.done.title": "設定完成!", + "onboarding.done.descWithKey": "按住 {{key}} 鍵說話,語音即可轉換為文字。", + "onboarding.done.descNoKey": "在設定中指定快速鍵後即可開始語音聽寫。", + "onboarding.done.start": "開始", + "onboarding.back": "返回", + "onboarding.next": "下一步", + + "hotkey.title": "快速鍵設定", + "hotkey.dictationTitle": "設定聽寫快速鍵", + "hotkey.oneTouchTitle": "設定一鍵模式快速鍵", + "hotkey.prompt": "請按下按鍵組合...", + "hotkey.ready": "✓ {{keys}} — 請點擊儲存", + "hotkey.noKey": "請輸入按鍵", + "hotkey.reserved": "{{keys}} 是系統保留快速鍵", + "hotkey.current": "目前: {{keys}}", + "hotkey.hint": "請輸入組合鍵(例如:Ctrl+Shift+Q)或單一鍵(例如:F5)", + "hotkey.reset": "重新輸入", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "STEP 1 — 安裝Ollama", + "ollama.step1.desc": "Ollama是一款在本地端執行LLM的免費工具。", + "ollama.step2.title": "STEP 2 — 下載模型", + "ollama.step2.desc": "在終端機中pull所需模型。推薦繁體中文模型:", + "ollama.step2.alt": "或更大的模型:ollama pull qwen3:8b(更精準,速度較慢)", + "ollama.step3.title": "STEP 3 — 自動連線", + "ollama.step3.desc": "Ollama啟動後,D3RO-VOICE將自動偵測。底部狀態列的LED從紅色變為綠色即表示準備就緒!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + + "common.cancel": "取消", + "common.save": "儲存", + "common.delete": "刪除", + "common.add": "新增", + "common.edit": "編輯", + "common.close": "關閉", + "common.confirm": "確認", + "common.loading": "載入中...", + "common.copy": "複製", + "common.test": "測試", + "common.stop": "停止", + + "date.today": "今天", + "date.yesterday": "昨天" +} diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json new file mode 100644 index 0000000..385dd8b --- /dev/null +++ b/src/renderer/i18n/zh.json @@ -0,0 +1,208 @@ +{ + "app.name": "D3RO Voice", + "app.tagline": "本地AI语音助手", + + "nav.dashboard": "仪表板", + "nav.history": "历史记录", + "nav.dictionary": "词典", + "nav.commands": "命令", + "nav.settings": "设置", + + "dashboard.sessionOverview": "会话概览", + "dashboard.systemStatus": "系统状态", + "dashboard.sessionsToday": "今日会话", + "dashboard.pressToRecord": "按下 {{key}} 开始录音", + "dashboard.hotkeyNotSet": "未设置热键", + "dashboard.words": "词数", + "dashboard.total": "总计", + "dashboard.streak": "连续", + "dashboard.days": "天", + "dashboard.recording": "录音", + "dashboard.sessions": "会话", + "dashboard.today": "今天", + "dashboard.recentTranscriptions": "最近转录", + "dashboard.noHistory": "暂无历史记录 — 按下热键开始录音", + "dashboard.copy": "复制", + "dashboard.entries": "{{count}}条", + "dashboard.stat": "统计", + "dashboard.sys": "系统", + + "history.title": "转录历史", + "history.search": "搜索...", + "history.entries": "{{count}}条", + "history.loading": "加载中...", + "history.noResults": "无搜索结果", + "history.noHistory": "暂无历史记录 — 请开始录音", + "history.count": "{{count}}条", + + "dictionary.title": "自定义词典", + "dictionary.words": "{{count}}个词", + "dictionary.add": "添加", + "dictionary.search": "搜索...", + "dictionary.loading": "加载中...", + "dictionary.noResults": "无搜索结果", + "dictionary.noWords": "暂无词条 — 添加自定义词汇以提高STT识别精度", + "dictionary.used": "已使用{{count}}次", + "dictionary.editTitle": "编辑词条", + "dictionary.addTitle": "添加词条", + "dictionary.word": "词汇", + "dictionary.pronunciation": "发音(可选)", + + "commands.title": "LLM命令", + "commands.count": "{{count}}个", + "commands.add": "添加", + "commands.activeCommand": "当前命令", + "commands.none": "无", + "commands.loading": "加载中...", + "commands.noCommands": "暂无命令 — 点击添加以创建", + "commands.editTitle": "编辑命令", + "commands.addTitle": "添加命令", + "commands.name": "名称", + "commands.description": "描述", + "commands.promptTemplate": "提示词模板", + "commands.promptHelp": "{{text}} 将被替换为转录的文本", + "commands.defaultPrompt": "请润色以下内容:{{text}}", + + "settings.title": "设置", + "settings.tabs.general": "通用", + "settings.tabs.audio": "音频", + "settings.tabs.stt": "STT", + "settings.tabs.llm": "LLM", + "settings.tabs.about": "关于", + + "settings.shortcuts": "快捷键", + "settings.dictation": "听写", + "settings.dictation.desc": "按住键开始说话,松开键后开始转录。", + "settings.agent": "Agent模式", + "settings.agent.descWithKey": "双击 {{key}} 进入Agent模式。", + "settings.agent.descNoKey": "请先设置听写热键。", + "settings.oneTouch": "一触即发模式", + "settings.oneTouch.desc": "按下开始,再次按下停止。需要单独的快捷键。", + "settings.key": "键", + "settings.notSet": "未设置", + "settings.enabled": "已启用", + "settings.disabled": "已禁用", + + "settings.interface": "界面", + "settings.theme": "主题", + "settings.theme.system": "跟随系统", + "settings.theme.light": "浅色", + "settings.theme.dark": "深色", + "settings.language": "语言", + + "settings.appBehavior": "应用行为", + "settings.closeToTray": "最小化到托盘", + "settings.autoLaunch": "开机自动启动", + "settings.autoInsert": "转录后自动插入文本", + "settings.soundEffects": "音效", + + "settings.microphone": "麦克风", + "settings.inputDevice": "输入设备", + "settings.deviceDefault": "(默认)", + "settings.textInsert": "文本插入", + "settings.insertMethod": "插入方式", + "settings.insertClipboard": "剪贴板(Ctrl+V)", + "settings.insertKeyboard": "键盘输入", + + "settings.whisperModel": "Whisper模型", + "settings.model.tiny": "tiny(39 MB,最快)", + "settings.model.base": "base(74 MB,均衡)", + "settings.model.small": "small(244 MB,良好)", + "settings.model.medium": "medium(769 MB,优秀)", + "settings.model.large": "large-v3(1.5 GB,最佳)", + "settings.sttLanguage": "识别语言", + "settings.sttLang.auto": "自动检测", + + "settings.ollamaServer": "Ollama服务器", + "settings.ollamaUrl": "Ollama服务器URL", + "settings.ollamaHint": "Ollama运行时将自动连接。请直接在Ollama中pull模型(例如:ollama pull qwen3:4b)。", + "settings.postProcess": "语音后处理", + "settings.defaultAction": "默认后处理命令", + "settings.action.none": "无(保留原始文本)", + "settings.action.refine": "润色(语法+自然度)", + "settings.action.translate": "翻译", + "settings.action.summarize": "摘要", + "settings.action.grammar": "语法校正", + "settings.action.custom": "自定义提示词", + "settings.actionHint": "通过热键录音后,所选LLM后处理将应用于转录文本。仅在Ollama连接时有效。", + + "settings.about.version": "版本", + "settings.about.techStack": "技术栈", + "settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript", + "settings.about.voiceEngine": "语音引擎", + "settings.about.voiceEngineValue": "STT: faster-whisper(本地)/ LLM: Ollama(本地)", + "settings.about.description": "基于Speakly逆向工程经验构建的本地AI语音助手,无需依赖云端,完全在本地运行。", + "settings.about.restartOnboarding": "重新查看初始设置引导", + + "status.ollama": "OLLAMA", + "status.offline": "OFFLINE", + "status.nudge.title": "Ollama未运行", + "status.nudge.desc": "使用LLM后处理(翻译、摘要等)需要Ollama。", + "status.nudge.guide": "查看安装指南 →", + + "onboarding.welcome.title": "D3RO-VOICE", + "onboarding.welcome.desc": "无需打字,用声音操作。本地AI语音助手。", + "onboarding.welcome.start": "开始", + "onboarding.mic.title": "麦克风设置", + "onboarding.mic.desc": "选择要使用的麦克风,之后可在设置中更改。", + "onboarding.hotkey.title": "快捷键设置", + "onboarding.hotkey.desc": "设置听写快捷键,按住该键时进行录音。", + "onboarding.hotkey.notSet": "尚未设置快捷键", + "onboarding.hotkey.change": "更改快捷键", + "onboarding.hotkey.set": "设置快捷键", + "onboarding.ollama.title": "安装Ollama(可选)", + "onboarding.ollama.desc": "使用翻译、摘要等LLM后处理功能需要Ollama。语音听写本身无需Ollama即可使用。", + "onboarding.ollama.download": "下载Ollama", + "onboarding.ollama.modelHint": "安装后在终端中下载模型", + "onboarding.done.title": "设置完成!", + "onboarding.done.descWithKey": "按住 {{key}} 键说话,语音即可转换为文本。", + "onboarding.done.descNoKey": "在设置中指定快捷键后即可开始语音听写。", + "onboarding.done.start": "开始", + "onboarding.back": "返回", + "onboarding.next": "下一步", + + "hotkey.title": "快捷键设置", + "hotkey.dictationTitle": "设置听写快捷键", + "hotkey.oneTouchTitle": "设置一触即发模式快捷键", + "hotkey.prompt": "请按下按键组合...", + "hotkey.ready": "✓ {{keys}} — 请点击保存", + "hotkey.noKey": "请输入按键", + "hotkey.reserved": "{{keys}} 是系统保留快捷键", + "hotkey.current": "当前: {{keys}}", + "hotkey.hint": "请输入组合键(例如:Ctrl+Shift+Q)或单个键(例如:F5)", + "hotkey.reset": "重新输入", + + "ollama.title": "OLLAMA SETUP GUIDE", + "ollama.step1.title": "STEP 1 — 安装Ollama", + "ollama.step1.desc": "Ollama是一款在本地运行LLM的免费工具。", + "ollama.step2.title": "STEP 2 — 下载模型", + "ollama.step2.desc": "在终端中pull所需模型。推荐中文模型:", + "ollama.step2.alt": "或更大的模型:ollama pull qwen3:8b(更精准,更慢)", + "ollama.step3.title": "STEP 3 — 自动连接", + "ollama.step3.desc": "Ollama启动后,D3RO-VOICE将自动检测。底部状态栏的LED从红色变为绿色即表示准备就绪!", + + "service.sttEngine": "STT ENGINE", + "service.ollamaLlm": "OLLAMA LLM", + "service.hotkeyHook": "HOTKEY HOOK", + "service.audioInput": "AUDIO INPUT", + "service.ready": "READY", + "service.connected": "CONNECTED", + "service.offline": "OFFLINE", + "service.active": "ACTIVE", + "service.standby": "STANDBY", + + "common.cancel": "取消", + "common.save": "保存", + "common.delete": "删除", + "common.add": "添加", + "common.edit": "编辑", + "common.close": "关闭", + "common.confirm": "确认", + "common.loading": "加载中...", + "common.copy": "复制", + "common.test": "测试", + "common.stop": "停止", + + "date.today": "今天", + "date.yesterday": "昨天" +} diff --git a/src/renderer/pages/CommandsPage.tsx b/src/renderer/pages/CommandsPage.tsx index bddb87a..bfea8b0 100644 --- a/src/renderer/pages/CommandsPage.tsx +++ b/src/renderer/pages/CommandsPage.tsx @@ -1,14 +1,20 @@ // src/renderer/pages/CommandsPage.tsx -// 커스텀 LLM 명령어 관리: 클릭하면 활성 명령어로 설정 → 다음 녹음 시 적용 +// 커스텀 LLM 명령어 관리 + Phase 10: 음성 키워드 + LLM 체인 +// 3 섹션: 명령어 목록 / 음성 키워드 / LLM 체인 import { useState, useEffect, useCallback } from 'react' -import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Typography } from '@mui/material' +import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Chip, Select, MenuItem, FormControl, InputLabel } 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 CheckCircleIcon from '@mui/icons-material/CheckCircle' -import { MetalCard, PhosphorText, Led } from '../components/ds' -import { d3roPalette, d3roFontMono } from '../theme' +import PlayArrowIcon from '@mui/icons-material/PlayArrow' +import LinkIcon from '@mui/icons-material/Link' +import { MetalCard, PhosphorText, Led, ScreenPanel } from '../components/ds' +import { PageHeader, EmptyStateCard } from '../components/shared' +import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme' +import { useI18n } from '../i18n' +import type { VoiceCommandRule, VoiceCommandKeyword, KeywordMatchMode, LLMChain, ChainStep } from '@shared/types' interface CustomInstruction { id: string @@ -21,6 +27,7 @@ interface CustomInstruction { } export function CommandsPage(): React.ReactElement { + const { t } = useI18n() const [instructions, setInstructions] = useState([]) const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) @@ -58,9 +65,8 @@ export function CommandsPage(): React.ReactElement { }, [loadData]) const handleActivate = (id: string) => { - const newId = activeId === id ? null : id // 토글: 같은 거 누르면 해제 + const newId = activeId === id ? null : id setActiveId(newId) - // ConfigService에 저장 + defaultLLMAction을 'custom'으로 변경 if (newId) { window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: newId as never }) window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' }) @@ -74,7 +80,7 @@ export function CommandsPage(): React.ReactElement { setEditId(null) setFormName('') setFormDesc('') - setFormPrompt('{{text}}를 다듬어주세요.') + setFormPrompt(t('commands.defaultPrompt')) setDialogOpen(true) } @@ -119,103 +125,453 @@ export function CommandsPage(): React.ReactElement { return ( - - - LLM INSTRUCTIONS — {instructions.length} COMMANDS - - - + } onClick={openAdd} size="small"> + {t('commands.add').toUpperCase()} + + } + /> - {/* 활성 명령어 표시 */} - - - ACTIVE COMMAND - - - {activeInstruction ? `● ${activeInstruction.name}` : '없음 — 명령어를 클릭하여 활성화'} - - - - {loading ? ( - LOADING... - ) : instructions.length === 0 ? ( - - - NO COMMANDS — CLICK ADD TO CREATE + {/* 활성 명령어 — ScreenPanel로 극적 표시 */} + + + + + + {t('commands.activeCommand').toUpperCase()} + + + {activeInstruction ? activeInstruction.name : t('commands.none')} + - - ) : ( - - {instructions.map((inst) => { - const isActive = inst.id === activeId - return ( - handleActivate(inst.id)} - sx={{ - cursor: 'pointer', - borderRadius: '22px', - border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent', - transition: 'border-color 0.15s ease', - }} - > - - - - {isActive ? ( - - ) : ( - - )} - - - {inst.name} - - - {inst.description} + + + + {/* 명령어 목록 */} + + {loading ? ( + {t('common.loading').toUpperCase()} + ) : instructions.length === 0 ? ( + + ) : ( + + {instructions.map((inst) => { + const isActive = inst.id === activeId + return ( + handleActivate(inst.id)} + sx={{ + cursor: 'pointer', + borderRadius: '22px', + border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent', + transition: 'border-color 0.15s ease', + }} + > + + + + {isActive ? ( + + ) : ( + + )} + + + {inst.name} + + + {inst.description} + - - e.stopPropagation()}> - openEdit(inst)} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} - > - - - {!inst.isBuiltin && ( + e.stopPropagation()}> handleDelete(inst.id)} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }} + onClick={() => openEdit(inst)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} > - + - )} + {!inst.isBuiltin && ( + handleDelete(inst.id)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }} + > + + + )} + - - - - ) - })} - - )} + + + ) + })} + + )} + + + {/* ── 음성 키워드 섹션 ── */} + + + {/* ── LLM 체인 섹션 ── */} + setDialogOpen(false)} maxWidth="sm" fullWidth> - {editId ? '명령어 편집' : '명령어 추가'} + {editId ? t('commands.editTitle') : t('commands.addTitle')} - setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> - setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} /> - setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="{{text}}는 전사된 텍스트로 치환됩니다" /> + setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> + setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} /> + setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText={t('commands.promptHelp')} /> - - + + + + + + ) +} + +/* ──────────────────────────────────────────────────────────── + 음성 키워드 섹션: 명령어별 키워드 편집 + ──────────────────────────────────────────────────────────── */ + +function VoiceKeywordsSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement { + const { t } = useI18n() + const [rules, setRules] = useState([]) + const [editingRule, setEditingRule] = useState(null) + const [keywordInput, setKeywordInput] = useState('') + + const loadRules = useCallback(async () => { + const result = await window.electronAPI.voiceCommand.getAll() + if (result.success) setRules(result.data) + }, []) + + useEffect(() => { loadRules() }, [loadRules]) + + const handleAddKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[]) => { + const trimmed = keywordInput.trim() + if (!trimmed) return + const updated: VoiceCommandKeyword[] = [...existing, { keyword: trimmed, matchMode: 'prefix' as KeywordMatchMode }] + await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated }) + setKeywordInput('') + loadRules() + }, [keywordInput, loadRules]) + + const handleRemoveKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[], idx: number) => { + const updated = existing.filter((_, i) => i !== idx) + await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated }) + loadRules() + }, [loadRules]) + + const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id + + return ( + + + + {t('voiceCommand.title').toUpperCase()} + + + + + {rules.length === 0 ? ( + + {t('voiceCommand.noKeywords').toUpperCase()} + + ) : ( + + {rules.map(rule => ( + + + + + {getInstructionName(rule.instructionId)} + + + {rule.keywords.map((kw, idx) => ( + handleRemoveKeyword(rule.instructionId, rule.keywords, idx)} + sx={{ + height: 20, + fontFamily: d3roFontMono, + fontSize: d3roTypo.micro.size, + bgcolor: d3roPalette.tag.orangeBg, + color: d3roPalette.tag.orange, + borderRadius: d3roRadius.small, + '& .MuiChip-deleteIcon': { color: d3roPalette.tag.orange, fontSize: 12 }, + }} + /> + ))} + {editingRule === rule.instructionId ? ( + ) => setKeywordInput(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(rule.instructionId, rule.keywords) } + if (e.key === 'Escape') { setEditingRule(null); setKeywordInput('') } + }} + onBlur={() => { if (!keywordInput.trim()) setEditingRule(null) }} + autoFocus + placeholder={t('voiceCommand.keywordPlaceholder')} + sx={{ + border: `1px solid ${d3roPalette.border.subtle}`, + bgcolor: d3roPalette.bg.input, + color: d3roPalette.text.primary, + fontFamily: d3roFontMono, + fontSize: d3roTypo.micro.size, + px: 1, py: 0.25, + borderRadius: d3roRadius.xs, + outline: 'none', width: 120, + '&:focus': { borderColor: d3roPalette.accent.amber }, + }} + /> + ) : ( + { setEditingRule(rule.instructionId); setKeywordInput('') }} + sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }} + > + + + )} + + + + ))} + + )} + + ) +} + +/* ──────────────────────────────────────────────────────────── + LLM 체인 섹션: 멀티 명령어 파이프라인 관리 + ──────────────────────────────────────────────────────────── */ + +function ChainSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement { + const { t } = useI18n() + const [chains, setChains] = useState([]) + const [dialogOpen, setDialogOpen] = useState(false) + const [editChain, setEditChain] = useState(null) + const [formName, setFormName] = useState('') + const [formSteps, setFormSteps] = useState([]) + + const loadChains = useCallback(async () => { + const result = await window.electronAPI.chain.getAll() + if (result.success) setChains(result.data) + }, []) + + useEffect(() => { loadChains() }, [loadChains]) + + const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id + + const openAdd = () => { + setEditChain(null) + setFormName('') + setFormSteps([{ instructionId: instructions[0]?.id ?? '', inputSource: 'original' }]) + setDialogOpen(true) + } + + const openEdit = (chain: LLMChain) => { + setEditChain(chain) + setFormName(chain.name) + setFormSteps([...chain.steps]) + setDialogOpen(true) + } + + const handleSave = async () => { + if (!formName.trim() || formSteps.length === 0) return + const validSteps = formSteps.filter(s => s.instructionId) + if (validSteps.length === 0) return + + if (editChain) { + await window.electronAPI.chain.update({ id: editChain.id, name: formName.trim(), steps: validSteps }) + } else { + await window.electronAPI.chain.create({ name: formName.trim(), steps: validSteps }) + } + setDialogOpen(false) + loadChains() + } + + const handleDelete = async (id: string) => { + await window.electronAPI.chain.delete({ id }) + loadChains() + } + + const handleExecute = async (chainId: string) => { + const text = await navigator.clipboard.readText() + if (text) { + await window.electronAPI.chain.execute({ chainId, inputText: text }) + } + } + + const addStep = () => { + setFormSteps(prev => [...prev, { + instructionId: instructions[0]?.id ?? '', + inputSource: prev.length > 0 ? 'previous' : 'original', + }]) + } + + const removeStep = (idx: number) => { + setFormSteps(prev => prev.filter((_, i) => i !== idx)) + } + + const updateStep = (idx: number, field: keyof ChainStep, value: string) => { + setFormSteps(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s)) + } + + return ( + + + + {t('chain.title').toUpperCase()} + + + + + + {chains.length === 0 ? ( + + ) : ( + + {chains.map(chain => ( + + + + + {chain.name} + + + {chain.steps.map((step, idx) => ( + + {idx > 0 && ( + + )} + + + ))} + + + + handleExecute(chain.id)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.green } }}> + + + openEdit(chain)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> + + + handleDelete(chain.id)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> + + + + + + ))} + + )} + + {/* 체인 편집 다이얼로그 */} + setDialogOpen(false)} maxWidth="sm" fullWidth> + + {editChain ? t('chain.editTitle') : t('chain.addTitle')} + + + setFormName(e.target.value)} + fullWidth autoFocus sx={{ mt: 1 }} + /> + + + {t('chain.steps').toUpperCase()} + + + {formSteps.map((step, idx) => ( + + + {idx + 1}. + + + {t('chain.selectInstruction')} + + + {idx > 0 && ( + + {t('chain.inputSource')} + + + )} + removeStep(idx)} disabled={formSteps.length <= 1} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> + + + + ))} + + + + + + diff --git a/src/renderer/pages/DashboardPage.tsx b/src/renderer/pages/DashboardPage.tsx index e949eda..0fa1dd7 100644 --- a/src/renderer/pages/DashboardPage.tsx +++ b/src/renderer/pages/DashboardPage.tsx @@ -1,103 +1,30 @@ // src/renderer/pages/DashboardPage.tsx -// 기능 중심 대시보드: 통계 카드 + 서비스 상태 + 최근 히스토리 (정밀기기 비주얼) +// 레퍼런스(Meteorological Instrument) 스타일 대시보드: +// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리 -import { useState, useEffect, useCallback, useMemo } from 'react' -import { Box, Typography, IconButton, Tooltip } from '@mui/material' -import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText } from '../components/ds' -import { d3roPalette, d3roFontMono } from '../theme' -import type { StatsSummary, HistoryEntry, HistoryPage as HistoryPageData, HotkeyBinding } from '@shared/types' - -// ── 유틸 ────────────────────────────────────────────── - -function formatRecordingTime(ms: number): string { - const totalMin = Math.round(ms / 60000) - if (totalMin >= 60) { - const h = Math.floor(totalMin / 60) - const m = totalMin % 60 - return `${h}:${m.toString().padStart(2, '0')}` - } - return `${totalMin}` -} - -function formatRecordingTimeUnit(ms: number): string { - const totalMin = Math.round(ms / 60000) - return totalMin >= 60 ? '시간' : '분' -} - -function formatNumber(n: number): string { - if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M` - if (n >= 1000) return `${(n / 1000).toFixed(1)}K` - return `${n}` -} - -function formatDuration(sec: number): string { - const m = Math.floor(sec / 60) - const s = Math.round(sec % 60) - return `${m}:${s.toString().padStart(2, '0')}` -} - -function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) -} - -function getDateLabel(ts: number): string { - const d = new Date(ts) - d.setHours(0, 0, 0, 0) - const today = new Date() - today.setHours(0, 0, 0, 0) - const yesterday = new Date(today) - yesterday.setDate(yesterday.getDate() - 1) - - if (d.getTime() === today.getTime()) return 'TODAY' - if (d.getTime() === yesterday.getTime()) return 'YESTERDAY' - return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }).toUpperCase() -} - -function getDateKey(ts: number): string { - const d = new Date(ts) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` -} - -// ── 통계 카드 ───────────────────────────────────────── - -function StatCard({ - label, - value, - unit, -}: { - label: string - value: string - unit?: string -}): React.ReactElement { - return ( - - - - {label} - - - - {value} - - {unit && ( - - {unit} - - )} - - - - ) -} +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Box } from '@mui/material' +import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '../components/ds' +import { EmptyStateCard, HistoryEntryCard } from '../components/shared' +import { d3roPalette, d3roTypo } from '../theme' +import { useI18n } from '../i18n' +import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters' +import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@shared/types' // ── 메인 컴포넌트 ───────────────────────────────────── export function DashboardPage(): React.ReactElement { + const { t, formatTime, formatRelativeDate } = useI18n() const [stats, setStats] = useState(null) const [history, setHistory] = useState([]) const [ollamaConnected, setOllamaConnected] = useState(false) const [dictationBinding, setDictationBinding] = useState(null) + const [activeView, setActiveView] = useState<'stats' | 'status'>('stats') + const [captionState, setCaptionState] = useState('inactive') + const [audioLevel, setAudioLevel] = useState(0) + const audioDecayRef = useRef | null>(null) + const [licenseTier, setLicenseTier] = useState('free') + const [usageQuotas, setUsageQuotas] = useState([]) const loadData = useCallback(() => { window.electronAPI.stats.getSummary().then((r) => { @@ -112,14 +39,36 @@ export function DashboardPage(): React.ReactElement { window.electronAPI.hotkey.getDictationShortcut().then((r) => { if (r.success && r.data) setDictationBinding(r.data) }) + window.electronAPI.caption.getState().then((r) => { + if (r.success) setCaptionState(r.data) + }) + window.electronAPI.license.getInfo().then((r) => { + if (r.success) setLicenseTier(r.data.tier) + }) + window.electronAPI.license.getAllUsage().then((r) => { + if (r.success) setUsageQuotas(r.data) + }) }, []) useEffect(() => { loadData() const interval = setInterval(loadData, 30000) - // 실시간 갱신: 세션 완료/명령어 변경 시 즉시 리로드 const unsub = window.electronAPI.app.onDataChanged(() => { loadData() }) - return () => { clearInterval(interval); unsub() } + const unsubCaption = window.electronAPI.caption.onStateChanged((state) => { + setCaptionState(state) + }) + const unsubAudio = window.electronAPI.voice.onAudioLevel((e) => { + setAudioLevel(e.level) + }) + // 오디오 이벤트가 없을 때 서서히 감쇠 + audioDecayRef.current = setInterval(() => { + setAudioLevel(prev => prev > 0.01 ? prev * 0.85 : 0) + }, 100) + return () => { + clearInterval(interval) + if (audioDecayRef.current) clearInterval(audioDecayRef.current) + unsub(); unsubCaption(); unsubAudio() + } }, [loadData]) // 날짜별 그룹핑 @@ -128,141 +77,269 @@ export function DashboardPage(): React.ReactElement { for (const entry of history) { const key = getDateKey(entry.createdAt) if (!groups[key]) { - groups[key] = { label: getDateLabel(entry.createdAt), entries: [] } + groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] } } groups[key].entries.push(entry) } return Object.values(groups) - }, [history]) + }, [history, formatRelativeDate]) - // 서비스 상태 목록 - const services = [ - { name: 'STT ENGINE', status: 'READY', ok: true }, - { name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' : 'OFFLINE', ok: ollamaConnected }, - { name: 'HOTKEY HOOK', status: 'ACTIVE', ok: true }, - { name: 'AUDIO INPUT', status: 'STANDBY', ok: true }, - ] + const services = useMemo(() => [ + { name: t('service.sttEngine'), status: t('service.ready'), ok: true }, + { name: t('service.ollamaLlm'), status: ollamaConnected ? t('service.connected') : t('service.offline'), ok: ollamaConnected }, + { name: t('service.hotkeyHook'), status: t('service.active'), ok: true }, + { name: t('service.audioInput'), status: t('service.standby'), ok: true }, + ], [t, ollamaConnected]) return ( - {/* ── 1. HERO 영역 ──────────────────────────── */} - + {/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */} - - - - - - 타이핑 없이, D3RO-VOICE만으로 + + {/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */} + + {/* 상단 라벨 */} + + + {activeView === 'stats' + ? t('dashboard.sessionOverview').toUpperCase() + : t('dashboard.systemStatus').toUpperCase()} + + + {new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()} - - {/* 핫키 표시 */} - - {dictationBinding ? ( - - {dictationBinding.displayLabel.split(' + ').map((key) => ( - - {key} - - ))} + {/* 중앙: 큰 수치 or 서비스 상태 */} + + {activeView === 'stats' ? ( + <> + + + {stats?.todaySessionCount ?? 0} + + + {t('dashboard.sessionsToday').toUpperCase()} + + + + {dictationBinding + ? t('dashboard.pressToRecord', { key: dictationBinding.displayLabel.toUpperCase() }).toUpperCase() + : t('dashboard.hotkeyNotSet').toUpperCase()} + + + ) : ( + + {services.map((svc) => ( + + + {svc.name} + + {svc.status} + + + ))} + + )} + + + {/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */} + {activeView === 'stats' && ( + + + {t('dashboard.words').toUpperCase()} + + + {formatNumber(stats?.totalWordCount ?? 0)} + + + {t('dashboard.total').toUpperCase()} + + + + + {t('dashboard.streak').toUpperCase()} + + + {stats?.streakDays ?? 0} + + + {t('dashboard.days').toUpperCase()} + + + - ) : ( - 핫키 미설정 )} - - 키를 누른 상태에서 받아쓰기. 더블클릭하면 Agent 모드. - + + + {/* 우측: 컨트롤 패널 */} + + {/* LED 상태 클러스터 */} + + + + + + {/* 버튼 그룹 */} + + setActiveView('stats')} + sx={{ minWidth: 0 }} + > + {t('dashboard.stat').toUpperCase()} + + setActiveView('status')} + sx={{ minWidth: 0 }} + > + {t('dashboard.sys').toUpperCase()} + + - - {/* ── 2. 통계 카드 ──────────────────────────── */} + {/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */} - - - - + {[ + { label: t('dashboard.recording').toUpperCase(), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0) }, + { label: t('dashboard.words').toUpperCase(), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total').toUpperCase() }, + { label: t('dashboard.today').toUpperCase(), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions').toUpperCase() }, + { label: t('dashboard.streak').toUpperCase(), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days').toUpperCase() }, + ].map((card) => ( + + + + {card.label} + + + + {card.value} + + + {card.unit} + + + + + ))} - {/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */} - - - + {/* ── 2.4. 사용량 바 (Free 티어) ─────────────── */} + {licenseTier === 'free' && usageQuotas.length > 0 && ( + + + + + {t('license.usageToday').toUpperCase()} + + {usageQuotas.map((q) => ( + + + + {t(`license.feature.${q.feature}`)} + + + {q.limit === -1 + ? t('license.unlimited') + : `${q.used}/${q.limit}`} + + + {q.limit > 0 && ( + + = q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber, + borderRadius: '2px', + transition: 'width 0.3s ease', + }} /> + + )} + + ))} + + + + )} + + {/* ── 2.5. 실시간 자막 토글 ────────────────── */} + + + + + + + + {t('dashboard.caption').toUpperCase()} + + {captionState === 'active' && ( + + {t('dashboard.captionActive').toUpperCase()} + + )} + + + { + if (captionState === 'active') { + await window.electronAPI.caption.stop() + } else if (captionState === 'inactive') { + await window.electronAPI.caption.start() + } + }} + sx={{ minWidth: 100 }} + > + {captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()} + + + + + + {/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */} + + + {services.map((svc) => ( - - {svc.name} - + {svc.name} {svc.status} @@ -273,19 +350,13 @@ export function DashboardPage(): React.ReactElement { {/* ── 4. 최근 히스토리 ──────────────────────── */} - + - RECENT TRANSCRIPTIONS + {t('dashboard.recentTranscriptions').toUpperCase()} {history.length === 0 ? ( - - - - 히스토리 없음 — 핫키를 눌러 녹음을 시작하세요 - - - + ) : ( groupedHistory.map((group) => ( @@ -295,60 +366,19 @@ export function DashboardPage(): React.ReactElement { {group.label} - - 더 보기 + + {t('dashboard.entries', { count: group.entries.length })} {/* 히스토리 항목 */} {group.entries.map((entry) => ( - - - - - - {entry.polishedText || entry.originalText} - - - {formatTime(entry.createdAt)} - {formatDuration(entry.duration)} - {entry.detectedLanguage && {entry.detectedLanguage.toUpperCase()}} - {entry.mode.toUpperCase()} - - - - navigator.clipboard.writeText(entry.polishedText || entry.originalText)} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} - > - - - - - + navigator.clipboard.writeText(text)} + /> ))} diff --git a/src/renderer/pages/DictionaryPage.tsx b/src/renderer/pages/DictionaryPage.tsx index 4c18b61..efe555c 100644 --- a/src/renderer/pages/DictionaryPage.tsx +++ b/src/renderer/pages/DictionaryPage.tsx @@ -1,19 +1,21 @@ // src/renderer/pages/DictionaryPage.tsx -// 인스트루먼트 미학: MetalCard + PhosphorText +// 인스트루먼트 미학: MetalCard + PhosphorText + 타이포 토큰 import { useState, useEffect, useCallback } from 'react' -import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material' +import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions } 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 SearchIcon from '@mui/icons-material/Search' -import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds' -import { d3roPalette, d3roFontMono } from '../theme' +import { MetalCard, PhosphorText } from '../components/ds' +import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared' +import { d3roPalette, d3roFontMono, d3roTypo } from '../theme' +import { useI18n } from '../i18n' import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types' const PAGE_SIZE = 50 export function DictionaryPage(): React.ReactElement { + const { t } = useI18n() const [data, setData] = useState(null) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) @@ -67,32 +69,26 @@ export function DictionaryPage(): React.ReactElement { return ( - - - CUSTOM DICTIONARY — {data?.total ?? 0} WORDS - - - + } onClick={openAdd} size="small"> + {t('dictionary.add').toUpperCase()} + + } + /> - setSearch(e.target.value)} - fullWidth - sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' } }} - slotProps={{ input: { startAdornment: } }} + onChange={setSearch} /> {loading ? ( - LOADING... + {t('common.loading').toUpperCase()} ) : !data || data.entries.length === 0 ? ( - - - {search ? 'NO RESULTS' : 'NO WORDS — ADD CUSTOM WORDS FOR BETTER STT'} - - + ) : ( {data.entries.map((entry: DictionaryEntry) => ( @@ -100,13 +96,19 @@ export function DictionaryPage(): React.ReactElement { - {entry.word} + {entry.word} {entry.pronunciation && ( - [{entry.pronunciation}] + [{entry.pronunciation}] )} - - {entry.category.toUpperCase()} · {entry.usageCount}× USED + + {entry.category.toUpperCase()} · {t('dictionary.used', { count: entry.usageCount })} @@ -126,14 +128,14 @@ export function DictionaryPage(): React.ReactElement { )} setDialogOpen(false)} maxWidth="xs" fullWidth> - {editId ? '단어 편집' : '단어 추가'} + {editId ? t('dictionary.editTitle') : t('dictionary.addTitle')} - setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> - setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} /> + setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> + setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} /> - - + + diff --git a/src/renderer/pages/HistoryPage.tsx b/src/renderer/pages/HistoryPage.tsx index 6fce813..20d2163 100644 --- a/src/renderer/pages/HistoryPage.tsx +++ b/src/renderer/pages/HistoryPage.tsx @@ -1,109 +1,167 @@ // src/renderer/pages/HistoryPage.tsx // 인스트루먼트 미학: MetalCard + PhosphorText + Led + 날짜 그룹핑 +// Phase 10: 태그 필터링 + 태그 관리 통합 import { useState, useEffect, useCallback, useMemo } from 'react' -import { Box, TextField, IconButton, InputAdornment } from '@mui/material' -import SearchIcon from '@mui/icons-material/Search' -import DeleteIcon from '@mui/icons-material/Delete' -import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import { MetalCard, PhosphorText, Led } from '../components/ds' -import { d3roPalette, d3roFontMono } from '../theme' -import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types' +import { Box, Chip, IconButton, Tooltip } from '@mui/material' +import FileDownloadIcon from '@mui/icons-material/FileDownload' +import { PhosphorText } from '../components/ds' +import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius } from '../theme' +import { useI18n } from '../i18n' +import { getDateKey } from '../utils/formatters' +import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared' +import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@shared/types' const PAGE_SIZE = 50 -function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString('ko-KR', { 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')}` -} - -function getDateKey(ts: number): string { - const d = new Date(ts) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` -} - -function getDateLabel(ts: number): string { - const d = new Date(ts) - d.setHours(0, 0, 0, 0) - const today = new Date() - today.setHours(0, 0, 0, 0) - const yesterday = new Date(today) - yesterday.setDate(yesterday.getDate() - 1) - - if (d.getTime() === today.getTime()) return 'TODAY' - if (d.getTime() === yesterday.getTime()) return 'YESTERDAY' - return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }).toUpperCase() -} - export function HistoryPage(): React.ReactElement { + const { t, formatRelativeDate } = useI18n() const [data, setData] = useState(null) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) + const [allTags, setAllTags] = useState([]) + const [activeTag, setActiveTag] = useState(null) + + const loadTags = useCallback(async () => { + const result = await window.electronAPI.memo.getAllTags() + if (result.success) setAllTags(result.data) + }, []) const loadData = useCallback(async () => { setLoading(true) - const result = search.trim() - ? await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE }) - : await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' }) + let result: { success: boolean; data: HistoryPageData } | { success: false; error: unknown } + + if (activeTag) { + result = await window.electronAPI.memo.searchByTag({ tag: activeTag, page: 0, pageSize: PAGE_SIZE }) + } else if (search.trim()) { + result = await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE }) + } else { + result = await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' }) + } if (result.success) setData(result.data) setLoading(false) - }, [search]) + }, [search, activeTag]) useEffect(() => { loadData() - const unsub = window.electronAPI.app.onDataChanged(() => { loadData() }) + loadTags() + const unsub = window.electronAPI.app.onDataChanged(() => { loadData(); loadTags() }) return unsub - }, [loadData]) + }, [loadData, loadTags]) - // 날짜별 그룹핑 const groupedEntries = useMemo(() => { if (!data) return [] const groups: Record = {} for (const entry of data.entries) { const key = getDateKey(entry.createdAt) if (!groups[key]) { - groups[key] = { label: getDateLabel(entry.createdAt), entries: [] } + groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] } } groups[key].entries.push(entry) } return Object.values(groups) - }, [data]) + }, [data, formatRelativeDate]) + + const handleCopy = useCallback((text: string) => { + navigator.clipboard.writeText(text) + }, []) + + const handleDelete = useCallback((id: string) => { + window.electronAPI.history.delete({ id }) + loadData() + }, [loadData]) + + const handleTagClick = useCallback((tag: string) => { + setActiveTag(prev => prev === tag ? null : tag) + setSearch('') + }, []) + + const handleExport = useCallback(async () => { + await window.electronAPI.memo.export({ + format: 'markdown' as const, + tag: activeTag ?? undefined, + }) + }, [activeTag]) return ( - - TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES - - - setSearch(e.target.value)} - fullWidth - sx={{ - mb: 3, - '& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' }, - }} - slotProps={{ - input: { - startAdornment: , - }, - }} + {/* 페이지 헤더 -- 각인 스타일 */} + + + + + + + + {t('history.entries', { count: data?.total ?? 0 }).toUpperCase()} + + + } /> + {/* 태그 필터 바 */} + {allTags.length > 0 && ( + + {allTags.map(tc => ( + handleTagClick(tc.tag)} + sx={{ + height: 22, + fontFamily: d3roFontMono, + fontSize: d3roTypo.micro.size, + borderRadius: d3roRadius.small, + borderColor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.border.subtle, + bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : 'transparent', + color: activeTag === tc.tag ? d3roPalette.bg.card : d3roPalette.text.secondary, + '&:hover': { + bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.bg.cardHover, + }, + }} + /> + ))} + {activeTag && ( + setActiveTag(null)} + sx={{ + height: 22, + fontFamily: d3roFontMono, + fontSize: d3roTypo.micro.size, + borderRadius: d3roRadius.small, + color: d3roPalette.text.muted, + '&:hover': { color: d3roPalette.tag.red }, + }} + /> + )} + + )} + + {!activeTag && ( + + )} + {loading ? ( - LOADING... + {t('common.loading').toUpperCase()} ) : !data || data.entries.length === 0 ? ( - - - {search ? 'NO RESULTS' : 'NO HISTORY — START RECORDING'} - - + ) : ( groupedEntries.map((group) => ( @@ -113,44 +171,22 @@ export function HistoryPage(): React.ReactElement { {group.label} - - {group.entries.length}건 + + {t('history.count', { count: group.entries.length })} {/* 항목 */} {group.entries.map((entry: HistoryEntry) => ( - - - - - - {entry.polishedText || entry.originalText} - - - {formatTime(entry.createdAt)} - {formatDuration(entry.duration)} - {entry.detectedLanguage && {entry.detectedLanguage.toUpperCase()}} - {entry.mode.toUpperCase()} - - - - navigator.clipboard.writeText(entry.polishedText || entry.originalText)} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> - - - { window.electronAPI.history.delete({ id: entry.id }); loadData() }} - sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}> - - - - - + ))} diff --git a/src/renderer/popups/caption-overlay/index.html b/src/renderer/popups/caption-overlay/index.html new file mode 100644 index 0000000..b9b33f8 --- /dev/null +++ b/src/renderer/popups/caption-overlay/index.html @@ -0,0 +1,17 @@ + + + + + + + Live Caption + + +
+
+
+
+
+ + + diff --git a/src/renderer/popups/caption-overlay/script.js b/src/renderer/popups/caption-overlay/script.js new file mode 100644 index 0000000..47edb96 --- /dev/null +++ b/src/renderer/popups/caption-overlay/script.js @@ -0,0 +1,193 @@ +// Caption Overlay 팝업 스크립트 +// Phase 10.1: Live Caption — 실시간 자막 오버레이 +// Vanilla JS (다른 팝업과 동일한 패턴) + +;(function () { + 'use strict' + + // ── 설정 (기본값, IPC로 업데이트) ───────────────────── + var config = { + fontSize: 18, + opacity: 0.85, + maxLines: 3, + autoClearMs: 5000 + } + + // ── DOM 참조 ───────────────────────────────────────── + var linesContainer = document.getElementById('lines') + var container = document.getElementById('container') + + // ── 상태 ────────────────────────────────────────────── + /** @type {Array<{el: HTMLElement, timer: number|null, id: string}>} */ + var lines = [] + /** @type {HTMLElement|null} */ + var deltaLine = null + + // ── 자막 줄 추가 ────────────────────────────────────── + + /** + * 확정된 자막 세그먼트를 추가한다. + * @param {{id: string, text: string, timestamp: number, isFinal: boolean}} segment + */ + function addSegment(segment) { + // delta 줄이 있으면 제거 (확정 줄로 교체) + removeDeltaLine() + + var el = document.createElement('div') + el.className = 'caption-line' + el.textContent = segment.text + el.style.fontSize = config.fontSize + 'px' + linesContainer.appendChild(el) + + // auto-clear 타이머 설정 + var timer = setTimeout(function () { + fadeAndRemoveLine(entry) + }, config.autoClearMs) + + var entry = { el: el, timer: timer, id: segment.id } + lines.push(entry) + + // maxLines 초과 시 가장 오래된 줄 제거 + while (lines.length > config.maxLines) { + var oldest = lines.shift() + if (oldest) { + if (oldest.timer) clearTimeout(oldest.timer) + if (oldest.el.parentNode) { + oldest.el.parentNode.removeChild(oldest.el) + } + } + } + } + + /** + * 중간(delta) 자막을 업데이트한다 (아직 확정되지 않은 줄). + * @param {{text: string, isFinal: boolean}} data + */ + function updateDelta(data) { + if (data.isFinal) { + // isFinal이면 segment로 처리 + removeDeltaLine() + return + } + + if (!deltaLine) { + deltaLine = document.createElement('div') + deltaLine.className = 'caption-line delta' + deltaLine.style.fontSize = config.fontSize + 'px' + linesContainer.appendChild(deltaLine) + } + + deltaLine.textContent = data.text + } + + /** + * delta 줄을 제거한다. + */ + function removeDeltaLine() { + if (deltaLine && deltaLine.parentNode) { + deltaLine.parentNode.removeChild(deltaLine) + } + deltaLine = null + } + + /** + * 줄을 페이드 아웃 후 제거한다. + * @param {{el: HTMLElement, timer: number|null, id: string}} entry + */ + function fadeAndRemoveLine(entry) { + entry.el.classList.add('fading') + setTimeout(function () { + if (entry.el.parentNode) { + entry.el.parentNode.removeChild(entry.el) + } + var idx = lines.indexOf(entry) + if (idx !== -1) { + lines.splice(idx, 1) + } + }, 500) // CSS transition 시간과 일치 + } + + /** + * 모든 줄을 제거한다. + */ + function clearAllLines() { + for (var i = 0; i < lines.length; i++) { + if (lines[i].timer) clearTimeout(lines[i].timer) + if (lines[i].el.parentNode) { + lines[i].el.parentNode.removeChild(lines[i].el) + } + } + lines = [] + removeDeltaLine() + } + + /** + * 설정을 적용한다. + * @param {object} newConfig + */ + function applyConfig(newConfig) { + if (newConfig.fontSize !== undefined) config.fontSize = newConfig.fontSize + if (newConfig.opacity !== undefined) config.opacity = newConfig.opacity + if (newConfig.maxLines !== undefined) config.maxLines = newConfig.maxLines + if (newConfig.autoClearMs !== undefined) config.autoClearMs = newConfig.autoClearMs + + // 기존 줄에 폰트 크기 반영 + for (var i = 0; i < lines.length; i++) { + lines[i].el.style.fontSize = config.fontSize + 'px' + } + if (deltaLine) { + deltaLine.style.fontSize = config.fontSize + 'px' + } + + // 컨테이너 투명도 + if (container) { + container.style.opacity = String(config.opacity) + } + } + + // ── IPC 리스너 ──────────────────────────────────────── + + if (window.popupAPI) { + // 확정된 자막 세그먼트 + window.popupAPI.on('caption:segment', function (segment) { + addSegment(segment) + }) + + // 중간 전사 결과 (delta) + window.popupAPI.on('caption:delta', function (data) { + updateDelta(data) + }) + + // 설정 업데이트 + window.popupAPI.on('caption:config', function (newConfig) { + applyConfig(newConfig) + }) + + // 숨기기 (세션 종료) + window.popupAPI.on('caption:hide', function () { + clearAllLines() + }) + + // 상태 변경 + window.popupAPI.on('caption:stateChanged', function (data) { + if (data.state === 'starting') { + // 로딩 표시 + clearAllLines() + var loadingEl = document.createElement('div') + loadingEl.className = 'caption-line loading' + loadingEl.id = 'caption-loading' + loadingEl.textContent = '⏳ Loading STT model...' + loadingEl.style.fontSize = config.fontSize + 'px' + linesContainer.appendChild(loadingEl) + } else if (data.state === 'active') { + // 로딩 표시 제거 + var existing = document.getElementById('caption-loading') + if (existing && existing.parentNode) { + existing.parentNode.removeChild(existing) + } + } else if (data.state === 'inactive' || data.state === 'stopping') { + clearAllLines() + } + }) + } +})() diff --git a/src/renderer/popups/caption-overlay/style.css b/src/renderer/popups/caption-overlay/style.css new file mode 100644 index 0000000..81ad4b7 --- /dev/null +++ b/src/renderer/popups/caption-overlay/style.css @@ -0,0 +1,114 @@ +/* Caption Overlay — Phase 10.1 Live Caption + * CSS Custom Properties 기반 — fallback은 dark 테마값. + * WindowManager.insertCSS()가 :root에 테마 변수를 주입하면 자동 전환. + */ + +/* ── 다크 테마 fallback 기본값 (:root) ─────────────────────────── */ +:root { + --d3-accent-main: #f25b29; + --d3-accent-glow: rgba(242, 91, 41, 0.6); + --d3-accent-glow-dim: rgba(242, 91, 41, 0.3); + --d3-accent-light: #ff8a65; + --d3-accent-light-glow: rgba(255, 138, 101, 0.7); + --d3-accent-light-glow-dim: rgba(255, 138, 101, 0.4); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + background: transparent; + overflow: hidden; + user-select: none; + -webkit-app-region: no-drag; +} + +#root { + width: 100%; + height: 100%; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.caption-overlay { + width: 100%; + padding: 12px 20px; + display: flex; + flex-direction: column; + justify-content: flex-end; + pointer-events: none; +} + +#lines { + display: flex; + flex-direction: column; + gap: 4px; +} + +.caption-line { + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 18px; + font-weight: 500; + line-height: 1.5; + color: var(--d3-accent-main); + text-shadow: + 0 0 8px var(--d3-accent-glow), + 0 0 16px var(--d3-accent-glow-dim), + 0 1px 3px rgba(0, 0, 0, 0.8); + background: rgba(0, 0, 0, 0.92); + border-radius: 6px; + padding: 4px 12px; + opacity: 1; + transition: opacity 0.5s ease-out; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.caption-line.fading { + opacity: 0; +} + +/* Newest line (bottom) is brightest */ +.caption-line:last-child { + color: var(--d3-accent-light); + text-shadow: + 0 0 10px var(--d3-accent-light-glow), + 0 0 20px var(--d3-accent-light-glow-dim), + 0 1px 3px rgba(0, 0, 0, 0.8); +} + +/* Oldest lines are dimmer */ +.caption-line:first-child { + opacity: 0.6; +} + +.caption-line:nth-child(2) { + opacity: 0.8; +} + +/* Delta (partial/unfinished) line has pulsing cursor */ +.caption-line.delta::after { + content: '\2588'; + animation: blink 0.8s step-end infinite; + margin-left: 2px; + opacity: 0.7; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* 로딩 중 표시 — 점멸 애니메이션 */ +.caption-line.loading { + opacity: 0.6; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.8; } +} diff --git a/src/renderer/popups/command-popup/index.html b/src/renderer/popups/command-popup/index.html index 07a81cc..ddc49a4 100644 --- a/src/renderer/popups/command-popup/index.html +++ b/src/renderer/popups/command-popup/index.html @@ -14,6 +14,7 @@
↑↓ 선택 ⏎ 적용 + 0 해제 ESC ×
diff --git a/src/renderer/popups/command-popup/script.js b/src/renderer/popups/command-popup/script.js index 0088fee..95d4a87 100644 --- a/src/renderer/popups/command-popup/script.js +++ b/src/renderer/popups/command-popup/script.js @@ -11,6 +11,7 @@ var selectedIndex = 0 var currentCommands = [] var currentActiveId = null + var i18nStrings = {} // ── 아이템 렌더링 ─────────────────────────────────── function renderItems(commands, activeId) { @@ -22,19 +23,51 @@ if (commands.length === 0) { var empty = document.createElement('div') empty.style.cssText = 'color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 24px 16px;' - empty.textContent = '명령어가 없습니다' + empty.textContent = i18nStrings.noCommands || 'No commands' itemsContainer.appendChild(empty) return } + // ── "선택 해제" 항목 (맨 위) ── + var noneDiv = document.createElement('div') + noneDiv.className = 'item item-none' + (0 === selectedIndex ? ' selected' : '') + (!activeId ? ' active' : '') + noneDiv.dataset.index = '0' + + var noneNum = document.createElement('span') + noneNum.className = 'item-number' + noneNum.textContent = '0' + var noneName = document.createElement('span') + noneName.className = 'item-name' + noneName.style.opacity = '0.5' + noneName.textContent = i18nStrings.noCommand || 'No command (insert original)' + + if (!activeId) { + var noneBadge = document.createElement('span') + noneBadge.className = 'item-active-badge' + noneBadge.textContent = '●' + noneDiv.appendChild(noneNum) + noneDiv.appendChild(noneName) + noneDiv.appendChild(noneBadge) + } else { + noneDiv.appendChild(noneNum) + noneDiv.appendChild(noneName) + } + + itemsContainer.appendChild(noneDiv) + items.push(noneDiv) + noneDiv.addEventListener('click', function () { + selectAndApply(-1) + }) + commands.forEach(function (cmd, index) { + var itemIndex = index + 1 var div = document.createElement('div') - div.className = 'item' + (index === selectedIndex ? ' selected' : '') + (cmd.id === activeId ? ' active' : '') - div.dataset.index = String(index) + div.className = 'item' + (itemIndex === selectedIndex ? ' selected' : '') + (cmd.id === activeId ? ' active' : '') + div.dataset.index = String(itemIndex) var num = document.createElement('span') num.className = 'item-number' - num.textContent = String(index + 1) + num.textContent = String(itemIndex) var name = document.createElement('span') name.className = 'item-name' @@ -59,16 +92,17 @@ items.push(div) div.addEventListener('click', function () { - selectAndApply(index) + selectAndApply(itemIndex) }) }) } // ── 선택 업데이트 ─────────────────────────────────── function updateSelection(newIndex) { - if (currentCommands.length === 0) return - if (newIndex < 0) newIndex = currentCommands.length - 1 - if (newIndex >= currentCommands.length) newIndex = 0 + var totalItems = currentCommands.length + 1 // +1 for "none" item + if (totalItems <= 1) return + if (newIndex < 0) newIndex = totalItems - 1 + if (newIndex >= totalItems) newIndex = 0 items.forEach(function (item, i) { if (i === newIndex) { @@ -86,14 +120,21 @@ // ── 선택 적용 ─────────────────────────────────────── function selectAndApply(index) { - if (index < 0 || index >= currentCommands.length) return - var cmd = currentCommands[index] + // index 0 또는 -1 = "선택 해제" + if (index <= 0) { + window.popupAPI.send('command:selected', { id: '', name: '' }) + return + } + var cmdIndex = index - 1 + if (cmdIndex < 0 || cmdIndex >= currentCommands.length) return + var cmd = currentCommands[cmdIndex] window.popupAPI.send('command:selected', { id: cmd.id, name: cmd.name }) } // ── IPC 리스너 ─────────────────────────────────────── window.popupAPI.on('command:showItems', function (data) { selectedIndex = 0 + if (data._i18n) i18nStrings = data._i18n renderItems(data.commands || [], data.activeId || null) container.classList.remove('hiding') }) @@ -119,9 +160,11 @@ selectAndApply(selectedIndex) } else if (key === 'Escape') { window.popupAPI.send('command:dismissed', {}) + } else if (key === '0') { + selectAndApply(0) } else if (key >= '1' && key <= '9') { - var idx = parseInt(key, 10) - 1 - if (idx < currentCommands.length) { + var idx = parseInt(key, 10) + if (idx <= currentCommands.length) { selectAndApply(idx) } } diff --git a/src/renderer/popups/command-popup/style.css b/src/renderer/popups/command-popup/style.css index 6c44e4f..6dfa83d 100644 --- a/src/renderer/popups/command-popup/style.css +++ b/src/renderer/popups/command-popup/style.css @@ -1,3 +1,23 @@ +/* command-popup/style.css + * CSS Custom Properties 기반 — fallback은 dark 테마값. + * WindowManager.insertCSS()가 :root에 테마 변수를 주입하면 자동 전환. + */ + +/* ── 다크 테마 fallback 기본값 (:root) ─────────────────────────── */ +:root { + --d3-bg-card: #242427; + --d3-border-default: rgba(255, 255, 255, 0.08); + --d3-border-subtle: rgba(255, 255, 255, 0.06); + --d3-text-primary: rgba(255, 255, 255, 0.87); + --d3-text-inactive: rgba(255, 255, 255, 0.3); + --d3-text-muted: rgba(255, 255, 255, 0.25); + --d3-text-dimLabel: #5c2615; + --d3-text-secondary: rgba(255, 255, 255, 0.35); + --d3-accent-main: #f25b29; + --d3-accent-dim: rgba(242, 91, 41, 0.08); + --d3-shadow-popup: 0 8px 32px rgba(0, 0, 0, 0.5); +} + * { margin: 0; padding: 0; @@ -17,11 +37,11 @@ body { } .command-popup { - background: #242427; /* d3roPalette.bg.card */ - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--d3-bg-card); + border: 1px solid var(--d3-border-default); border-radius: 12px; padding: 6px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + box-shadow: var(--d3-shadow-popup); opacity: 0; transform: translateY(4px) scale(0.98); transition: opacity 0.15s ease-out, transform 0.15s ease-out; @@ -42,7 +62,7 @@ body { } .title { - color: #5c2615; /* d3roPalette.text.dimLabel */ + color: var(--d3-text-dimLabel); font-size: 9px; font-weight: 700; letter-spacing: 1.5px; @@ -68,7 +88,7 @@ body { .item:hover, .item.selected { - background: rgba(255, 255, 255, 0.06); + background: var(--d3-border-subtle); } .item.selected::before { @@ -78,16 +98,16 @@ body { top: 6px; bottom: 6px; width: 3px; - background: #f25b29; /* d3roPalette.accent.amber */ + background: var(--d3-accent-main); border-radius: 1.5px; } .item.active { - background: rgba(242, 91, 41, 0.08); + background: var(--d3-accent-dim); } .item-number { - color: rgba(255, 255, 255, 0.3); + color: var(--d3-text-inactive); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; min-width: 16px; @@ -96,7 +116,7 @@ body { .item-name { flex: 1; - color: rgba(255, 255, 255, 0.87); + color: var(--d3-text-primary); font-size: 13px; font-weight: 600; white-space: nowrap; @@ -105,7 +125,7 @@ body { } .item-desc { - color: rgba(255, 255, 255, 0.35); + color: var(--d3-text-secondary); font-size: 11px; white-space: nowrap; flex-shrink: 0; @@ -114,8 +134,13 @@ body { text-overflow: ellipsis; } +.item-none { + border-bottom: 1px solid var(--d3-border-subtle); + margin-bottom: 2px; +} + .item-active-badge { - color: #f25b29; + color: var(--d3-accent-main); font-size: 10px; font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -126,11 +151,11 @@ body { justify-content: center; gap: 16px; padding: 6px 0 4px; - border-top: 1px solid rgba(255, 255, 255, 0.06); + border-top: 1px solid var(--d3-border-subtle); margin-top: 4px; } .hints span { - color: rgba(255, 255, 255, 0.25); + color: var(--d3-text-muted); font-size: 10px; } diff --git a/src/renderer/popups/history-popup/style.css b/src/renderer/popups/history-popup/style.css index f66c052..cbc8e11 100644 --- a/src/renderer/popups/history-popup/style.css +++ b/src/renderer/popups/history-popup/style.css @@ -1,3 +1,20 @@ +/* history-popup/style.css + * CSS Custom Properties 기반 — fallback은 dark 테마값. + * WindowManager.insertCSS()가 :root에 테마 변수를 주입하면 자동 전환. + */ + +/* ── 다크 테마 fallback 기본값 (:root) ─────────────────────────── */ +:root { + --d3-bg-card: #242427; + --d3-border-default: rgba(255, 255, 255, 0.08); + --d3-border-subtle: rgba(255, 255, 255, 0.06); + --d3-text-primary: rgba(255, 255, 255, 0.87); + --d3-text-inactive: rgba(255, 255, 255, 0.3); + --d3-text-muted: rgba(255, 255, 255, 0.25); + --d3-accent-main: #f25b29; + --d3-shadow-popup: 0 8px 32px rgba(0, 0, 0, 0.5); +} + * { margin: 0; padding: 0; @@ -17,11 +34,11 @@ body { } .history-popup { - background: #242427; - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--d3-bg-card); + border: 1px solid var(--d3-border-default); border-radius: 12px; padding: 6px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + box-shadow: var(--d3-shadow-popup); opacity: 0; transform: translateY(4px) scale(0.98); transition: opacity 0.15s ease-out, transform 0.15s ease-out; @@ -59,7 +76,7 @@ body { .item:hover, .item.selected { - background: rgba(255, 255, 255, 0.06); + background: var(--d3-border-subtle); } .item.selected::before { @@ -69,12 +86,12 @@ body { top: 4px; bottom: 4px; width: 3px; - background: #f25b29; + background: var(--d3-accent-main); border-radius: 1.5px; } .item-number { - color: rgba(255, 255, 255, 0.3); + color: var(--d3-text-inactive); font-size: 11px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; min-width: 16px; @@ -83,7 +100,7 @@ body { .item-text { flex: 1; - color: rgba(255, 255, 255, 0.87); + color: var(--d3-text-primary); font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.4; @@ -93,7 +110,7 @@ body { } .item-time { - color: rgba(255, 255, 255, 0.3); + color: var(--d3-text-inactive); font-size: 11px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; white-space: nowrap; @@ -105,18 +122,18 @@ body { justify-content: center; gap: 16px; padding: 6px 0 4px; - border-top: 1px solid rgba(255, 255, 255, 0.06); + border-top: 1px solid var(--d3-border-subtle); margin-top: 4px; } .hints span { - color: rgba(255, 255, 255, 0.25); + color: var(--d3-text-muted); font-size: 10px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .empty-state { - color: rgba(255, 255, 255, 0.4); + color: var(--d3-text-inactive); font-size: 13px; text-align: center; padding: 24px 16px; diff --git a/src/renderer/popups/recording-tip/script.js b/src/renderer/popups/recording-tip/script.js index a7ec56d..2e4fe81 100644 --- a/src/renderer/popups/recording-tip/script.js +++ b/src/renderer/popups/recording-tip/script.js @@ -55,8 +55,11 @@ // ── 웨이브 바 애니메이션 ───────────────────────────── function updateBars() { + // 최소 진동: audioLevel이 0이어도 바가 미세하게 움직여 "살아있음" 표현 + var effectiveLevel = Math.max(0.08, audioLevel) + for (var i = 0; i < BAR_COUNT; i++) { - var baseTarget = audioLevel * MAX_HEIGHT * weights[i] + var baseTarget = effectiveLevel * MAX_HEIGHT * weights[i] var randomized = baseTarget * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) var target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, randomized)) diff --git a/src/renderer/popups/recording-tip/style.css b/src/renderer/popups/recording-tip/style.css index e75b980..daf788f 100644 --- a/src/renderer/popups/recording-tip/style.css +++ b/src/renderer/popups/recording-tip/style.css @@ -1,3 +1,17 @@ +/* recording-tip/style.css + * CSS Custom Properties 기반 — fallback은 dark 테마값. + * WindowManager.insertCSS()가 :root에 테마 변수를 주입하면 자동 전환. + */ + +/* ── 다크 테마 fallback 기본값 (:root) ─────────────────────────── */ +:root { + --d3-bg-tip: rgba(0, 0, 0, 0.85); + --d3-text-primary: rgba(255, 255, 255, 0.87); + --d3-text-secondary: rgba(255, 255, 255, 0.6); + --d3-accent-main: #f25b29; + --d3-border-strong: rgba(255, 255, 255, 0.15); +} + * { margin: 0; padding: 0; @@ -20,7 +34,7 @@ body { } .recording-tip { - background: rgba(0, 0, 0, 0.85); + background: var(--d3-bg-tip); border-radius: 8px; padding: 8px 12px; display: flex; @@ -40,14 +54,14 @@ body { .wave-bar { width: 3px; - background: #f25b29; /* d3roPalette.accent.amber */ + background: var(--d3-accent-main); border-radius: 1.5px; transition: height 100ms ease-out; min-height: 2px; } .duration { - color: rgba(255, 255, 255, 0.87); + color: var(--d3-text-primary); font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-variant-numeric: tabular-nums; @@ -57,21 +71,21 @@ body { .progress-container { width: 120px; height: 3px; - background: rgba(255, 255, 255, 0.15); + background: var(--d3-border-strong); border-radius: 1.5px; overflow: hidden; } .progress-bar { height: 3px; - background: #f25b29; /* d3roPalette.accent.amber */ + background: var(--d3-accent-main); border-radius: 1.5px; width: 0%; transition: width 100ms linear; } .thinking-label { - color: rgba(255, 255, 255, 0.6); + color: var(--d3-text-secondary); font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } @@ -82,7 +96,7 @@ body { justify-content: center; width: 18px; height: 18px; - background: #ef4444; /* d3roPalette.tag.red */ + background: #ef4444; color: white; border-radius: 50%; font-size: 12px; @@ -90,7 +104,7 @@ body { } .error-label { - color: rgba(255, 255, 255, 0.87); + color: var(--d3-text-primary); font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } diff --git a/src/renderer/popups/result-popup/style.css b/src/renderer/popups/result-popup/style.css index b106264..423b805 100644 --- a/src/renderer/popups/result-popup/style.css +++ b/src/renderer/popups/result-popup/style.css @@ -1,3 +1,20 @@ +/* result-popup/style.css + * CSS Custom Properties 기반 — fallback은 dark 테마값. + * @media (prefers-color-scheme: dark) 블록 제거 → CSS 변수로 통합. + * WindowManager.insertCSS()가 :root에 테마 변수를 주입하면 자동 전환. + */ + +/* ── 다크 테마 fallback 기본값 (:root) ─────────────────────────── */ +:root { + --d3-bg-result: #1e1e1e; + --d3-border-result: rgba(255, 255, 255, 0.08); + --d3-text-result: rgba(255, 255, 255, 0.87); + --d3-action-btn: rgba(255, 255, 255, 0.4); + --d3-action-btn-hover-bg: rgba(255, 255, 255, 0.08); + --d3-action-btn-hover: rgba(255, 255, 255, 0.7); + --d3-shadow-popup: 0 8px 32px rgba(0, 0, 0, 0.5); +} + * { margin: 0; padding: 0; @@ -18,11 +35,11 @@ body { } .result-popup { - background: #FFFFFF; - border: 1px solid rgba(0, 0, 0, 0.08); + background: var(--d3-bg-result); + border: 1px solid var(--d3-border-result); border-radius: 12px; padding: 12px 16px; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + box-shadow: var(--d3-shadow-popup); opacity: 0; transform: translateY(4px); transition: opacity 200ms ease-out, transform 200ms ease-out; @@ -39,7 +56,7 @@ body { .result-text { flex: 1; - color: rgba(0, 0, 0, 0.87); + color: var(--d3-text-result); font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.5; @@ -61,42 +78,20 @@ body { border: none; background: transparent; border-radius: 6px; - color: rgba(0, 0, 0, 0.4); + color: var(--d3-action-btn); cursor: pointer; transition: background 150ms, color 150ms; } .action-btn:hover { - background: rgba(0, 0, 0, 0.06); - color: rgba(0, 0, 0, 0.7); + background: var(--d3-action-btn-hover-bg); + color: var(--d3-action-btn-hover); } .action-btn.copied { - color: #4CAF50; + color: #4caf50; } .hidden { display: none; } - -/* 다크모드 */ -@media (prefers-color-scheme: dark) { - .result-popup { - background: #1E1E1E; - border-color: rgba(255, 255, 255, 0.08); - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); - } - - .result-text { - color: rgba(255, 255, 255, 0.87); - } - - .action-btn { - color: rgba(255, 255, 255, 0.4); - } - - .action-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.7); - } -} diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts index ae50922..8764e24 100644 --- a/src/renderer/theme.ts +++ b/src/renderer/theme.ts @@ -1,27 +1,199 @@ // src/renderer/theme.ts // 08-design-system.md SSOT 기반 MUI 테마. -// D3RO 다크(기본) + 라이트 + auto(시스템). 나중에 커스텀 테마 추가 가능. +// CSS Custom Properties 기반: d3roPalette가 var()를 사용하여 테마 전환 시 자동 반응. import { createTheme, type Theme } from '@mui/material/styles' +import type { ThemeMode } from '@shared/types' -// ── SSOT: 디자인 시스템 팔레트 상수 ─────────────────────── +// ── 테마 키 타입 ──────────────────────────────────────────── +type ThemeKey = 'dark' | 'light' | 'nord' | 'solarized' | 'catppuccin' | 'dracula' + +// ── 원시 색상값 구조 타입 ──────────────────────────────────── +interface RawTheme { + bg: { + app: string + card: string + cardHover: string + elevated: string + input: string + sidebar: string + inset: string + chassis: string + crtBezel: string + crtGlass: string + } + text: { + primary: string + secondary: string + label: string + disabled: string + engraving: string + inactive: string + dimLabel: string + muted: string + hover: string + } + border: { + subtle: string + default: string + strong: string + } + shadow: { + card: string + buttonBase: string + chassis: string + inset: string + insetDeep: string + buttonRaised: string + buttonPressed: string + screenGlow: string + tooltip: string + } + accent: { + main: string + dim: string + glow: string + light: string + dark: string + crtPhosphor: string + crtPhosphorDim: string + } +} + +// ── 원시 색상값 (raw values) ──────────────────────────────────── +// CSS 변수에 주입되는 실제 색상값. 컴포넌트에서 직접 사용하지 않는다. +const RAW: Record = { + dark: { + bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21', sidebar: '#1e1f21', inset: '#1b1c1e', chassis: '#242528', crtBezel: '#1a1a1c', crtGlass: '#050605' }, + text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e', engraving: '#1a1a1c', inactive: '#77797c', dimLabel: '#5c2615', muted: '#3a3b3f', hover: '#aaaaaa' }, + border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' }, + shadow: { + card: '0 8px 30px rgba(0,0,0,0.3)', + buttonBase: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)', + chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #111111, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)', + inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)', + insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #000, 0 1px 1px rgba(255,255,255,0.1)', + buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.2)', + buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000', + screenGlow: 'inset 0 0 20px rgba(0,0,0,0.8)', + tooltip: '0 8px 24px rgba(0,0,0,0.4)', + }, + accent: { main: '#f25b29', dim: 'rgba(242,91,41,0.15)', glow: 'rgba(242,91,41,0.6)', light: '#ff7a4d', dark: '#c44a22', crtPhosphor: '#f25b29', crtPhosphorDim: '#c44a22' }, + }, + light: { + bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#f7f7f9', elevated: '#f0f0f2', input: '#ffffff', sidebar: '#eeeef0', inset: '#e8e8ea', chassis: '#e2e2e5', crtBezel: '#d5d5d8', crtGlass: '#f0f0f2' }, + text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc', engraving: '#d0d0d3', inactive: '#8e8e93', dimLabel: '#b07040', muted: '#b0b0b4', hover: '#555555' }, + border: { subtle: 'rgba(0,0,0,0.06)', default: 'rgba(0,0,0,0.10)', strong: 'rgba(0,0,0,0.16)' }, + shadow: { + card: '0 4px 20px rgba(0,0,0,0.06)', + buttonBase: '0 2px 0 rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8)', + chassis: '0 40px 80px -20px rgba(0,0,0,0.08), 0 12px 0 #d5d5d8, 0 13px 4px rgba(0,0,0,0.06), inset 0 1px 1px rgba(255,255,255,0.8), inset 0 -1px 2px rgba(0,0,0,0.04)', + inset: 'inset 0 2px 6px rgba(0,0,0,0.08), 0 1px 1px rgba(255,255,255,0.6)', + insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.12), inset 0 0 0 1px rgba(0,0,0,0.06), 0 1px 1px rgba(255,255,255,0.8)', + buttonRaised: '0 3px 6px rgba(0,0,0,0.08), inset 0 1px 1px rgba(255,255,255,0.8), inset 0 -1px 2px rgba(0,0,0,0.04)', + buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.12), inset 0 0 0 1px rgba(0,0,0,0.06)', + screenGlow: 'inset 0 0 20px rgba(0,0,0,0.06)', + tooltip: '0 8px 24px rgba(0,0,0,0.08)', + }, + accent: { main: '#f25b29', dim: 'rgba(242,91,41,0.15)', glow: 'rgba(242,91,41,0.6)', light: '#ff7a4d', dark: '#c44a22', crtPhosphor: '#f25b29', crtPhosphorDim: '#c44a22' }, + }, + // ── Nord (https://www.nordtheme.com/) ────────────────────── + nord: { + bg: { app: '#2e3440', card: '#3b4252', cardHover: '#434c5e', elevated: '#3b4252', input: '#3b4252', sidebar: '#2e3440', inset: '#2e3440', chassis: '#3b4252', crtBezel: '#2e3440', crtGlass: '#242831' }, + text: { primary: '#eceff4', secondary: '#d8dee9', label: '#adb5c7', disabled: '#4c566a', engraving: '#2e3440', inactive: '#7b88a1', dimLabel: '#5e81ac', muted: '#434c5e', hover: '#e5e9f0' }, + border: { subtle: 'rgba(216,222,233,0.06)', default: 'rgba(216,222,233,0.10)', strong: 'rgba(216,222,233,0.16)' }, + shadow: { + card: '0 8px 30px rgba(0,0,0,0.35)', + buttonBase: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)', + chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #242831, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.4)', + inset: 'inset 0 2px 6px rgba(0,0,0,0.5), 0 1px 1px rgba(255,255,255,0.04)', + insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.8), inset 0 0 0 1px #242831, 0 1px 1px rgba(255,255,255,0.08)', + buttonRaised: '0 3px 6px rgba(0,0,0,0.35), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.2)', + buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.7), inset 0 0 0 1px #242831', + screenGlow: 'inset 0 0 20px rgba(0,0,0,0.6)', + tooltip: '0 8px 24px rgba(0,0,0,0.4)', + }, + // Nord Frost: #88c0d0 (차가운 시안) + accent: { main: '#88c0d0', dim: 'rgba(136,192,208,0.15)', glow: 'rgba(136,192,208,0.6)', light: '#a3d0de', dark: '#6aacbe', crtPhosphor: '#88c0d0', crtPhosphorDim: '#5e9daf' }, + }, + // ── Solarized Dark (https://ethanschoonover.com/solarized/) ─ + solarized: { + bg: { app: '#002b36', card: '#073642', cardHover: '#0d4250', elevated: '#073642', input: '#073642', sidebar: '#002b36', inset: '#001f28', chassis: '#073642', crtBezel: '#002b36', crtGlass: '#001f28' }, + text: { primary: '#eee8d5', secondary: '#93a1a1', label: '#839496', disabled: '#586e75', engraving: '#002b36', inactive: '#657b83', dimLabel: '#7a6c2e', muted: '#073642', hover: '#b2c0bf' }, + border: { subtle: 'rgba(131,148,150,0.08)', default: 'rgba(131,148,150,0.13)', strong: 'rgba(131,148,150,0.20)' }, + shadow: { + card: '0 8px 30px rgba(0,0,0,0.4)', + buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)', + chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #001f28, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.4)', + inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)', + insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #001f28, 0 1px 1px rgba(255,255,255,0.06)', + buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.06), inset 0 -1px 2px rgba(0,0,0,0.2)', + buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #001f28', + screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)', + tooltip: '0 8px 24px rgba(0,0,0,0.4)', + }, + // Solarized yellow: #b58900 (따뜻한 골드) + accent: { main: '#b58900', dim: 'rgba(181,137,0,0.15)', glow: 'rgba(181,137,0,0.6)', light: '#d4a017', dark: '#8a6800', crtPhosphor: '#b58900', crtPhosphorDim: '#8a6800' }, + }, + // ── Catppuccin Mocha (https://catppuccin.com/) ────────────── + catppuccin: { + bg: { app: '#1e1e2e', card: '#313244', cardHover: '#3a3a54', elevated: '#313244', input: '#181825', sidebar: '#181825', inset: '#11111b', chassis: '#313244', crtBezel: '#1e1e2e', crtGlass: '#11111b' }, + text: { primary: '#cdd6f4', secondary: '#bac2de', label: '#a6adc8', disabled: '#585b70', engraving: '#11111b', inactive: '#7f849c', dimLabel: '#585b70', muted: '#313244', hover: '#e6e9f8' }, + border: { subtle: 'rgba(205,214,244,0.04)', default: 'rgba(205,214,244,0.08)', strong: 'rgba(205,214,244,0.14)' }, + shadow: { + card: '0 8px 30px rgba(0,0,0,0.4)', + buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)', + chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #11111b, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.4)', + inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)', + insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #11111b, 0 1px 1px rgba(255,255,255,0.06)', + buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.06), inset 0 -1px 2px rgba(0,0,0,0.2)', + buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #11111b', + screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)', + tooltip: '0 8px 24px rgba(0,0,0,0.4)', + }, + // Catppuccin Mauve: #cba6f7 (보라 파스텔) + accent: { main: '#cba6f7', dim: 'rgba(203,166,247,0.15)', glow: 'rgba(203,166,247,0.6)', light: '#dfc0ff', dark: '#a67fd4', crtPhosphor: '#cba6f7', crtPhosphorDim: '#a67fd4' }, + }, + // ── Dracula (https://draculatheme.com/) ───────────────────── + dracula: { + bg: { app: '#282a36', card: '#44475a', cardHover: '#4f5266', elevated: '#383a4a', input: '#383a4a', sidebar: '#21222c', inset: '#21222c', chassis: '#44475a', crtBezel: '#282a36', crtGlass: '#1e2029' }, + text: { primary: '#f8f8f2', secondary: '#a9b0d0', label: '#8891b5', disabled: '#6272a4', engraving: '#21222c', inactive: '#6272a4', dimLabel: '#6272a4', muted: '#44475a', hover: '#ffffff' }, + border: { subtle: 'rgba(248,248,242,0.04)', default: 'rgba(248,248,242,0.08)', strong: 'rgba(248,248,242,0.14)' }, + shadow: { + card: '0 8px 30px rgba(0,0,0,0.4)', + buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)', + chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #1e2029, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.4)', + inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)', + insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #1e2029, 0 1px 1px rgba(255,255,255,0.08)', + buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.2)', + buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #1e2029', + screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)', + tooltip: '0 8px 24px rgba(0,0,0,0.4)', + }, + // Dracula Purple: #bd93f9 (시그니처 퍼플) + accent: { main: '#bd93f9', dim: 'rgba(189,147,249,0.15)', glow: 'rgba(189,147,249,0.6)', light: '#d4b8ff', dark: '#9a70e0', crtPhosphor: '#bd93f9', crtPhosphorDim: '#9a70e0' }, + }, +} + +// ── SSOT: d3roPalette — CSS Custom Properties로 테마 반응형 ── +// 컴포넌트에서 이 객체만 import하면 테마 자동 전환. export const d3roPalette = { bg: { - app: '#19191b', - card: '#242427', - cardHover: '#2a2a2d', - elevated: '#2e2e32', - input: '#1e1e21', - sidebar: '#1e1f21', - inset: '#1b1c1e', - chassis: '#242528', - crtBezel: '#1a1a1c', - crtGlass: '#050605', + app: 'var(--d3-bg-app)', + card: 'var(--d3-bg-card)', + cardHover: 'var(--d3-bg-cardHover)', + elevated: 'var(--d3-bg-elevated)', + input: 'var(--d3-bg-input)', + sidebar: 'var(--d3-bg-sidebar)', + inset: 'var(--d3-bg-inset)', + chassis: 'var(--d3-bg-chassis)', + crtBezel: 'var(--d3-bg-crtBezel)', + crtGlass: 'var(--d3-bg-crtGlass)', }, accent: { - amber: '#f25b29', - amberDim: 'rgba(242, 91, 41, 0.15)', - amberGlow: 'rgba(242, 91, 41, 0.6)', + amber: 'var(--d3-accent-main)', + amberDim: 'var(--d3-accent-dim)', + amberGlow: 'var(--d3-accent-glow)', }, tag: { purple: '#b854f5', @@ -37,26 +209,26 @@ export const d3roPalette = { orangeGlow: 'rgba(245, 158, 11, 0.6)', }, text: { - primary: '#ffffff', - secondary: '#8e8e93', - label: '#7c7c82', - disabled: '#4a4a4e', - engraving: '#1a1a1c', - inactive: '#77797c', - dimLabel: '#5c2615', - muted: '#3a3b3f', - hover: '#aaaaaa', + primary: 'var(--d3-text-primary)', + secondary: 'var(--d3-text-secondary)', + label: 'var(--d3-text-label)', + disabled: 'var(--d3-text-disabled)', + engraving: 'var(--d3-text-engraving)', + inactive: 'var(--d3-text-inactive)', + dimLabel: 'var(--d3-text-dimLabel)', + muted: 'var(--d3-text-muted)', + hover: 'var(--d3-text-hover)', }, border: { - subtle: 'rgba(255, 255, 255, 0.04)', - default: 'rgba(255, 255, 255, 0.08)', - strong: 'rgba(255, 255, 255, 0.12)', + subtle: 'var(--d3-border-subtle)', + default: 'var(--d3-border-default)', + strong: 'var(--d3-border-strong)', }, crt: { - phosphor: '#f25b29', - phosphorDim: '#c44a22', + phosphor: 'var(--d3-accent-crtPhosphor)', + phosphorDim: 'var(--d3-accent-crtPhosphorDim)', scanline: 'rgba(0, 0, 0, 0.15)', - bg: '#242528', + bg: 'var(--d3-bg-chassis)', }, led: { off: '#111111', @@ -73,42 +245,116 @@ export const d3roFontMono = [ '"Liberation Mono"', 'monospace', ].join(',') -// ── 모드별 변동 팔레트 ──────────────────────────────────── -interface ModePalette { - bg: { app: string; card: string; cardHover: string; elevated: string; input: string; sidebar: string; inset: string; chassis: string; crtBezel: string; crtGlass: string } - text: { primary: string; secondary: string; label: string; disabled: string; engraving: string; inactive: string; dimLabel: string; muted: string; hover: string } - border: { subtle: string; default: string; strong: string } -} +// ── SSOT: 타이포그래피 토큰 ───────────────────────────── +export const d3roTypo = { + hero: { size: '42px', weight: 300, spacing: '-2px', line: 1 }, + title: { size: '28px', weight: 300, spacing: '-1px', line: 1.2 }, + value: { size: '20px', weight: 400, spacing: '0.02em', line: 1 }, + heading: { size: '16px', weight: 600, spacing: '0.02em', line: 1.4 }, + body: { size: '14px', weight: 400, spacing: '0.01em', line: 1.5 }, + compact: { size: '13px', weight: 400, spacing: '0.01em', line: 1.5 }, + small: { size: '12px', weight: 600, spacing: '0.03em', line: 1.4 }, + meta: { size: '11px', weight: 600, spacing: '0.05em', line: 1.3 }, + label: { size: '10px', weight: 700, spacing: '2px', line: 1.2 }, + engrave: { size: '9px', weight: 700, spacing: '1.5px', line: 1 }, + micro: { size: '8px', weight: 700, spacing: '1.5px', line: 1 }, + nano: { size: '7px', weight: 700, spacing: '0.5px', line: 1 }, +} as const -const darkPalette: ModePalette = { - bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21', sidebar: '#1e1f21', inset: '#1b1c1e', chassis: '#242528', crtBezel: '#1a1a1c', crtGlass: '#050605' }, - text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e', engraving: '#1a1a1c', inactive: '#77797c', dimLabel: '#5c2615', muted: '#3a3b3f', hover: '#aaaaaa' }, - border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' }, -} +// ── SSOT: 그림자 토큰 (CSS Custom Properties 기반, 테마 자동 전환) ─── +export const d3roShadow = { + chassis: 'var(--d3-shadow-chassis)', + card: 'var(--d3-shadow-card)', + inset: 'var(--d3-shadow-inset)', + insetDeep: 'var(--d3-shadow-insetDeep)', + buttonRaised: 'var(--d3-shadow-buttonRaised)', + buttonPressed: 'var(--d3-shadow-buttonPressed)', + buttonActive: 'var(--d3-shadow-buttonPressed)', + dialog: 'var(--d3-shadow-card)', + tooltip: 'var(--d3-shadow-tooltip)', + screenGlow: 'var(--d3-shadow-screenGlow)', +} as const -const lightPalette: ModePalette = { - bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff', sidebar: '#eeeef0', inset: '#e8e8ea', chassis: '#e0e0e3', crtBezel: '#d0d0d3', crtGlass: '#f8f8fa' }, - text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc', engraving: '#d0d0d3', inactive: '#8e8e93', dimLabel: '#c4845a', muted: '#b0b0b4', hover: '#555555' }, - border: { subtle: 'rgba(0,0,0,0.04)', default: 'rgba(0,0,0,0.08)', strong: 'rgba(0,0,0,0.12)' }, +// ── SSOT: 반경 토큰 ──────────────────────────────────── +export const d3roRadius = { + outer: '24px', + card: '22px', + inner: '12px', + button: '8px', + small: '6px', + xs: '4px', + pill: '999px', +} as const + +// ── CSS Custom Properties 생성 ────────────────────────── +function buildCssVars(key: ThemeKey): Record { + const r = RAW[key] + return { + '--d3-bg-app': r.bg.app, + '--d3-bg-card': r.bg.card, + '--d3-bg-cardHover': r.bg.cardHover, + '--d3-bg-elevated': r.bg.elevated, + '--d3-bg-input': r.bg.input, + '--d3-bg-sidebar': r.bg.sidebar, + '--d3-bg-inset': r.bg.inset, + '--d3-bg-chassis': r.bg.chassis, + '--d3-bg-crtBezel': r.bg.crtBezel, + '--d3-bg-crtGlass': r.bg.crtGlass, + '--d3-text-primary': r.text.primary, + '--d3-text-secondary': r.text.secondary, + '--d3-text-label': r.text.label, + '--d3-text-disabled': r.text.disabled, + '--d3-text-engraving': r.text.engraving, + '--d3-text-inactive': r.text.inactive, + '--d3-text-dimLabel': r.text.dimLabel, + '--d3-text-muted': r.text.muted, + '--d3-text-hover': r.text.hover, + '--d3-border-subtle': r.border.subtle, + '--d3-border-default': r.border.default, + '--d3-border-strong': r.border.strong, + '--d3-shadow-card': r.shadow.card, + '--d3-shadow-buttonBase': r.shadow.buttonBase, + '--d3-shadow-chassis': r.shadow.chassis, + '--d3-shadow-inset': r.shadow.inset, + '--d3-shadow-insetDeep': r.shadow.insetDeep, + '--d3-shadow-buttonRaised': r.shadow.buttonRaised, + '--d3-shadow-buttonPressed': r.shadow.buttonPressed, + '--d3-shadow-screenGlow': r.shadow.screenGlow, + '--d3-shadow-tooltip': r.shadow.tooltip, + '--d3-accent-main': r.accent.main, + '--d3-accent-dim': r.accent.dim, + '--d3-accent-glow': r.accent.glow, + '--d3-accent-light': r.accent.light, + '--d3-accent-dark': r.accent.dark, + '--d3-accent-crtPhosphor': r.accent.crtPhosphor, + '--d3-accent-crtPhosphorDim': r.accent.crtPhosphorDim, + } } // ── 테마 팩토리 ─────────────────────────────────────────── -function createD3ROTheme(mode: 'dark' | 'light'): Theme { - const isDark = mode === 'dark' - const p = isDark ? darkPalette : lightPalette - const accent = d3roPalette.accent +function createD3ROTheme(key: ThemeKey): Theme { + // MUI palette.mode는 'dark' | 'light'만 허용. light 외 모두 dark 기반. + const muiMode: 'dark' | 'light' = key === 'light' ? 'light' : 'dark' + const r = RAW[key] + const cssVars = buildCssVars(key) + + // 동적 accent 색상 (CSS 변수가 아직 주입되기 전이므로 RAW 값 직접 사용) + const accentMain = r.accent.main + const accentDim = r.accent.dim + const accentLight = r.accent.light + const accentDark = r.accent.dark return createTheme({ palette: { - mode, - primary: { main: accent.amber, light: '#ff7a4d', dark: '#c44a22', contrastText: '#fff' }, + mode: muiMode, + primary: { main: accentMain, light: accentLight, dark: accentDark, 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, + background: { default: r.bg.app, paper: r.bg.card }, + text: { primary: r.text.primary, secondary: r.text.secondary, disabled: r.text.disabled }, + divider: r.border.default, }, typography: { fontFamily: d3roFontSans, @@ -119,13 +365,16 @@ function createD3ROTheme(mode: 'dark' | 'light'): Theme { 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 }, + caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: r.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 } }, + styleOverrides: { + ':root': cssVars, + body: { backgroundColor: r.bg.app, color: r.text.primary }, + }, }, MuiButton: { defaultProps: { disableElevation: true }, @@ -133,76 +382,143 @@ function createD3ROTheme(mode: 'dark' | 'light'): Theme { 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)' }, + boxShadow: r.shadow.buttonBase, + '&:active': { transform: 'translateY(2px)', boxShadow: 'var(--d3-shadow-buttonPressed)' }, + }, + containedPrimary: { + color: '#fff', + '&:hover': { backgroundColor: accentDark }, + }, + outlined: { + borderColor: r.border.strong, + color: r.text.primary, + '&:hover': { borderColor: accentMain, color: accentMain }, + }, + containedSecondary: { + backgroundColor: r.bg.elevated, + color: r.text.primary, + '&:hover': { backgroundColor: muiMode === 'dark' ? '#353539' : '#e5e5e7' }, }, - 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)', + backgroundColor: r.bg.card, borderRadius: 22, + borderTop: `1px solid ${r.border.subtle}`, + boxShadow: r.shadow.card, transition: 'background-color 0.2s ease', - '&:hover': { backgroundColor: p.bg.cardHover }, + '&:hover': { backgroundColor: r.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 }, + colorPrimary: { backgroundColor: accentDim, color: accentMain }, 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}` } } }, + MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: r.bg.app, borderRight: `1px solid ${r.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)' } }, + '&.Mui-selected': { backgroundColor: accentDim, color: accentMain, fontWeight: 600, '&:hover': { backgroundColor: accentDim } }, + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + backgroundColor: r.bg.card, borderRadius: 22, + border: `1px solid ${r.border.subtle}`, + boxShadow: 'var(--d3-shadow-card)', }, }, }, - 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 }, + backgroundColor: r.bg.input, borderRadius: 10, + color: r.text.primary, + '& fieldset': { borderColor: r.border.default }, + '&:hover fieldset': { borderColor: r.border.strong }, + '&.Mui-focused fieldset': { borderColor: accentMain }, }, }, }, }, - 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 } } } }, + MuiSelect: { + styleOverrides: { + select: { color: r.text.primary }, + icon: { color: r.text.secondary }, + }, + }, + MuiInputLabel: { + styleOverrides: { + root: { color: r.text.secondary, '&.Mui-focused': { color: accentMain } }, + }, + }, + MuiFormControlLabel: { + styleOverrides: { + label: { color: r.text.primary, fontSize: '14px' }, + }, + }, + MuiSwitch: { + styleOverrides: { + switchBase: { + '&.Mui-checked': { color: accentMain }, + '&.Mui-checked + .MuiSwitch-track': { backgroundColor: accentMain }, + }, + track: { backgroundColor: r.text.disabled }, + }, + }, + MuiMenuItem: { + styleOverrides: { + root: { + color: r.text.primary, + '&.Mui-selected': { backgroundColor: accentDim }, + }, + }, + }, + MuiTooltip: { + defaultProps: { arrow: true }, + styleOverrides: { + tooltip: { backgroundColor: r.bg.elevated, color: r.text.primary, fontSize: '12px', borderRadius: 8, border: `1px solid ${r.border.subtle}` }, + }, + }, + MuiTabs: { styleOverrides: { indicator: { backgroundColor: accentMain } } }, + MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentMain, 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 +export const themes: Record = { + dark: createD3ROTheme('dark'), + light: createD3ROTheme('light'), + nord: createD3ROTheme('nord'), + solarized: createD3ROTheme('solarized'), + catppuccin: createD3ROTheme('catppuccin'), + dracula: createD3ROTheme('dracula'), } -/** 현재 모드의 ModePalette 가져오기 (컴포넌트에서 직접 참조용) */ -export function getModePalette(mode: 'dark' | 'light'): ModePalette { - return mode === 'dark' ? darkPalette : lightPalette +// 하위 호환 export +export const darkTheme = themes.dark +export const lightTheme = themes.light + +export function getTheme(mode: ThemeMode, prefersDark = true): Theme { + if (mode === 'auto') return prefersDark ? themes.dark : themes.light + return themes[mode as ThemeKey] ?? themes.dark +} + +export function getModePalette(mode: ThemeMode): RawTheme { + return RAW[mode as ThemeKey] ?? RAW.dark } diff --git a/src/renderer/utils/formatters.ts b/src/renderer/utils/formatters.ts new file mode 100644 index 0000000..027bd1f --- /dev/null +++ b/src/renderer/utils/formatters.ts @@ -0,0 +1,39 @@ +// src/renderer/utils/formatters.ts +// 공유 포맷팅 유틸리티 — SSOT, 중복 제거 + +/** 초 단위 duration → "M:SS" 형식 */ +export function formatDuration(sec: number): string { + const m = Math.floor(sec / 60) + const s = Math.round(sec % 60) + return `${m}:${s.toString().padStart(2, '0')}` +} + +/** 타임스탬프 → "YYYY-MM-DD" 날짜 키 (그룹핑용) */ +export function getDateKey(ts: number): string { + const d = new Date(ts) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +/** 큰 숫자 → "1.2K" / "3.4M" 축약 */ +export function formatNumber(n: number): string { + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M` + if (n >= 1000) return `${(n / 1000).toFixed(1)}K` + return `${n}` +} + +/** ms → 녹음 시간 수치 ("12" 또는 "1:30") */ +export function formatRecordingTime(ms: number): string { + const totalMin = Math.round(ms / 60000) + if (totalMin >= 60) { + const h = Math.floor(totalMin / 60) + const m = totalMin % 60 + return `${h}:${m.toString().padStart(2, '0')}` + } + return `${totalMin}` +} + +/** ms → 녹음 시간 단위 ("HR" 또는 "MIN") */ +export function formatRecordingTimeUnit(ms: number): string { + const totalMin = Math.round(ms / 60000) + return totalMin >= 60 ? 'HR' : 'MIN' +} diff --git a/src/renderer/utils/systemAudioCapture.ts b/src/renderer/utils/systemAudioCapture.ts new file mode 100644 index 0000000..3636079 --- /dev/null +++ b/src/renderer/utils/systemAudioCapture.ts @@ -0,0 +1,98 @@ +// src/renderer/utils/systemAudioCapture.ts +// 시스템 오디오(데스크톱 소리) 캡처 — Electron setDisplayMediaRequestHandler + audio: 'loopback' +// contextIsolation: true 환경에서 preload IPC 브릿지를 통해 동작 + +let mediaStream: MediaStream | null = null +let audioContext: AudioContext | null = null +let processorNode: ScriptProcessorNode | null = null + +const TARGET_SAMPLE_RATE = 16000 + +/** + * 시스템 오디오 캡처를 시작한다. + * 1. preload를 통해 메인 프로세스에 loopback 핸들러 등록 요청 + * 2. getDisplayMedia로 시스템 오디오 MediaStream 획득 + * 3. ScriptProcessorNode로 PCM16 16kHz mono 변환 후 콜백 전달 + */ +export async function startSystemAudioCapture( + onAudioData: (pcm16Buffer: ArrayBuffer) => void, +): Promise { + if (mediaStream) { + throw new Error('System audio capture already active') + } + + // 1. 메인 프로세스에 loopback 핸들러 등록 + await window.electronAPI.caption.enableLoopback() + + // 2. getDisplayMedia — video: true 필수 (Chromium 제약), 이후 video track 제거 + try { + mediaStream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }) + } catch (err) { + // loopback 핸들러 해제 + await window.electronAPI.caption.disableLoopback() + throw err + } + + // 3. loopback 핸들러 해제 (다른 getDisplayMedia 호출에 영향 방지) + await window.electronAPI.caption.disableLoopback() + + // 4. 비디오 트랙 제거 + mediaStream.getVideoTracks().forEach((t) => { + t.stop() + mediaStream?.removeTrack(t) + }) + + const audioTrack = mediaStream.getAudioTracks()[0] + if (!audioTrack) { + stopSystemAudioCapture() + throw new Error('No audio track in loopback stream') + } + + // 5. AudioContext로 PCM 추출 + audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE }) + const source = audioContext.createMediaStreamSource(new MediaStream([audioTrack])) + + const bufferSize = 4096 + processorNode = audioContext.createScriptProcessor(bufferSize, 1, 1) + + processorNode.onaudioprocess = (event: AudioProcessingEvent) => { + const inputData = event.inputBuffer.getChannelData(0) + + // Float32 → PCM16 변환 + const pcm16 = new Int16Array(inputData.length) + for (let i = 0; i < inputData.length; i++) { + const s = Math.max(-1, Math.min(1, inputData[i])) + pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff + } + + onAudioData(pcm16.buffer) + } + + source.connect(processorNode) + processorNode.connect(audioContext.destination) +} + +export function stopSystemAudioCapture(): void { + if (processorNode) { + processorNode.disconnect() + processorNode.onaudioprocess = null + processorNode = null + } + + if (audioContext) { + audioContext.close().catch(() => { /* ignore */ }) + audioContext = null + } + + if (mediaStream) { + mediaStream.getTracks().forEach((t) => t.stop()) + mediaStream = null + } +} + +export function isSystemAudioCaptureActive(): boolean { + return mediaStream !== null +} diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 94d8552..c833b97 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -89,7 +89,31 @@ export enum ErrorCode { DictionaryExportFailed = 723, DictionaryImportInvalidFormat = 724, - // === Config (800-899) === + // === Phase 10: Memo Tags (730-739) === + MemoTagDuplicate = 730, + MemoTagNotFound = 731, + MemoExportFailed = 732, + + // === Phase 10: Voice Commands (740-749) === + VoiceCommandMatchFailed = 740, + VoiceCommandNotFound = 741, + + // === Phase 10: Screen Context (750-759) === + ContextCaptureFailed = 750, + ContextSelectedTextFailed = 751, + + // === Phase 10: LLM Chain (760-769) === + ChainNotFound = 760, + ChainExecutionFailed = 761, + ChainStepFailed = 762, + ChainCancelled = 763, + + // === Phase 10: Live Caption (770-779) === + CaptionStartFailed = 770, + CaptionAlreadyActive = 771, + CaptionSTTFailed = 772, + + // === Config (800-849) === ConfigReadFailed = 800, ConfigWriteFailed = 801, ConfigInvalidValue = 802, @@ -97,6 +121,18 @@ export enum ErrorCode { ConfigResetFailed = 804, ConfigMigrationFailed = 810, + // === License (850-869) === + LicenseKeyInvalid = 850, + LicenseKeyExpired = 851, + LicenseActivationFailed = 852, + LicenseDeactivationFailed = 853, + LicenseMachineIdMismatch = 854, + LicenseOfflineGraceExpired = 855, + LicenseVerificationFailed = 856, + FeatureNotAvailable = 860, + QuotaExceeded = 861, + TierRequired = 862, + // === System / Window (900-999) === WindowCreationFailed = 900, WindowNotFound = 901, diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index c5ae296..42ffb71 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -76,6 +76,8 @@ export const IPC_CHANNELS = { SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut', GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut', SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut', + GET_CAPTION_SHORTCUT: 'hotkey:getCaptionShortcut', + SET_CAPTION_SHORTCUT: 'hotkey:setCaptionShortcut', IS_ENABLED: 'hotkey:isEnabled', SET_ENABLED: 'hotkey:setEnabled', START_RECORDING: 'hotkey:startRecording', @@ -159,7 +161,79 @@ export const IPC_CHANNELS = { GET_WEEKLY: 'stats:getWeekly', // Main → Renderer events UPDATED: 'stats:updated' - } + }, + + // ── Phase 10: Memo Tags (10.3) ── + MEMO: { + GET_TAGS: 'memo:getTags', + ADD_TAG: 'memo:addTag', + REMOVE_TAG: 'memo:removeTag', + GET_ALL_TAGS: 'memo:getAllTags', + SEARCH_BY_TAG: 'memo:searchByTag', + EXPORT: 'memo:export', + }, + + // ── Phase 10: Voice Commands (10.5) ── + VOICE_COMMAND: { + GET_ALL: 'voiceCommand:getAll', + SET_KEYWORDS: 'voiceCommand:setKeywords', + SET_ENABLED: 'voiceCommand:setEnabled', + IS_ENABLED: 'voiceCommand:isEnabled', + // Main → Renderer events + MATCHED: 'voiceCommand:matched', + }, + + // ── Phase 10: Screen Context (10.2) ── + CONTEXT: { + CAPTURE: 'context:capture', + GET_CONFIG: 'context:getConfig', + SET_ENABLED: 'context:setEnabled', + IS_ENABLED: 'context:isEnabled', + }, + + // ── Phase 10: LLM Chain (10.4) ── + CHAIN: { + GET_ALL: 'chain:getAll', + CREATE: 'chain:create', + UPDATE: 'chain:update', + DELETE: 'chain:delete', + EXECUTE: 'chain:execute', + // Main → Renderer events + PROGRESS: 'chain:progress', + }, + + // ── Phase 10: Live Caption (10.1) ── + CAPTION: { + START: 'caption:start', + STOP: 'caption:stop', + GET_STATE: 'caption:getState', + SET_CONFIG: 'caption:setConfig', + GET_CONFIG: 'caption:getConfig', + /** 렌더러 → 메인: 시스템 오디오 PCM 데이터 전달 */ + SYSTEM_AUDIO_DATA: 'caption:systemAudioData', + // Main → Renderer events + SEGMENT: 'caption:segment', + DELTA: 'caption:delta', + STATE_CHANGED: 'caption:stateChanged', + SESSION_SAVED: 'caption:sessionSaved', + /** 메인 → 렌더러: 시스템 오디오 캡처 시작/정지 요청 */ + START_SYSTEM_AUDIO: 'caption:startSystemAudio', + STOP_SYSTEM_AUDIO: 'caption:stopSystemAudio', + }, + + // ── Phase 11: License & Monetization ── + LICENSE: { + GET_INFO: 'license:getInfo', + ACTIVATE: 'license:activate', + DEACTIVATE: 'license:deactivate', + CHECK_FEATURE: 'license:checkFeature', + GET_USAGE: 'license:getUsage', + GET_ALL_USAGE: 'license:getAllUsage', + GET_TIER_COMPARISON: 'license:getTierComparison', + // Main → Renderer events + UPGRADE_PROMPT: 'license:upgradePrompt', + TIER_CHANGED: 'license:tierChanged', + }, } as const // 타입 유틸리티: 채널명 유니온 추출 diff --git a/src/shared/theme-vars.ts b/src/shared/theme-vars.ts new file mode 100644 index 0000000..4f88853 --- /dev/null +++ b/src/shared/theme-vars.ts @@ -0,0 +1,199 @@ +// src/shared/theme-vars.ts +// 팝업 윈도우용 CSS 변수 맵. +// main process (WindowManager)에서 insertCSS로 팝업에 테마를 주입할 때 사용. +// renderer의 theme.ts RAW와 동기화 유지 필요. +// Node.js/Electron main 환경에서 import 가능 (DOM API 없음). + +export type PopupThemeKey = 'dark' | 'light' | 'nord' | 'solarized' | 'catppuccin' | 'dracula' + +interface PopupThemeVars { + /** 카드 배경 (팝업 본체) */ + '--d3-bg-card': string + /** 앱 배경 (recording-tip 반투명 배경) */ + '--d3-bg-tip': string + /** 주요 텍스트 */ + '--d3-text-primary': string + /** 보조 텍스트 */ + '--d3-text-secondary': string + /** 비활성 텍스트 (번호, 시간 등) */ + '--d3-text-inactive': string + /** 힌트/최약 텍스트 */ + '--d3-text-muted': string + /** dimLabel 텍스트 (command-popup 제목) */ + '--d3-text-dimLabel': string + /** 기본 테두리 */ + '--d3-border-default': string + /** 미묘한 테두리 (구분선, hover) */ + '--d3-border-subtle': string + /** 강한 테두리 (progress background) */ + '--d3-border-strong': string + /** 악센트 색상 (웨이브바, 선택 바) */ + '--d3-accent-main': string + /** 악센트 dim (active 배경) */ + '--d3-accent-dim': string + /** 팝업 박스 섀도 */ + '--d3-shadow-popup': string + /** 결과 팝업 배경 (불투명) */ + '--d3-bg-result': string + /** 결과 팝업 테두리 */ + '--d3-border-result': string + /** 결과 팝업 텍스트 */ + '--d3-text-result': string + /** 결과 팝업 버튼 색상 */ + '--d3-action-btn': string + /** 결과 팝업 버튼 hover 배경 */ + '--d3-action-btn-hover-bg': string + /** 결과 팝업 버튼 hover 색상 */ + '--d3-action-btn-hover': string +} + +const POPUP_THEME_VARS: Record = { + dark: { + '--d3-bg-card': '#242427', + '--d3-bg-tip': 'rgba(0,0,0,0.85)', + '--d3-text-primary': 'rgba(255,255,255,0.87)', + '--d3-text-secondary': 'rgba(255,255,255,0.6)', + '--d3-text-inactive': 'rgba(255,255,255,0.3)', + '--d3-text-muted': 'rgba(255,255,255,0.25)', + '--d3-text-dimLabel': '#5c2615', + '--d3-border-default': 'rgba(255,255,255,0.08)', + '--d3-border-subtle': 'rgba(255,255,255,0.06)', + '--d3-border-strong': 'rgba(255,255,255,0.15)', + '--d3-accent-main': '#f25b29', + '--d3-accent-dim': 'rgba(242,91,41,0.08)', + '--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)', + '--d3-bg-result': '#1e1e1e', + '--d3-border-result': 'rgba(255,255,255,0.08)', + '--d3-text-result': 'rgba(255,255,255,0.87)', + '--d3-action-btn': 'rgba(255,255,255,0.4)', + '--d3-action-btn-hover-bg': 'rgba(255,255,255,0.08)', + '--d3-action-btn-hover': 'rgba(255,255,255,0.7)', + }, + light: { + '--d3-bg-card': '#ffffff', + '--d3-bg-tip': 'rgba(255,255,255,0.92)', + '--d3-text-primary': 'rgba(0,0,0,0.87)', + '--d3-text-secondary': 'rgba(0,0,0,0.6)', + '--d3-text-inactive': 'rgba(0,0,0,0.35)', + '--d3-text-muted': 'rgba(0,0,0,0.25)', + '--d3-text-dimLabel': '#b07040', + '--d3-border-default': 'rgba(0,0,0,0.10)', + '--d3-border-subtle': 'rgba(0,0,0,0.06)', + '--d3-border-strong': 'rgba(0,0,0,0.14)', + '--d3-accent-main': '#f25b29', + '--d3-accent-dim': 'rgba(242,91,41,0.08)', + '--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.12)', + '--d3-bg-result': '#ffffff', + '--d3-border-result': 'rgba(0,0,0,0.08)', + '--d3-text-result': 'rgba(0,0,0,0.87)', + '--d3-action-btn': 'rgba(0,0,0,0.4)', + '--d3-action-btn-hover-bg': 'rgba(0,0,0,0.06)', + '--d3-action-btn-hover': 'rgba(0,0,0,0.7)', + }, + nord: { + '--d3-bg-card': '#3b4252', + '--d3-bg-tip': 'rgba(46,52,64,0.92)', + '--d3-text-primary': 'rgba(236,239,244,0.87)', + '--d3-text-secondary': 'rgba(216,222,233,0.6)', + '--d3-text-inactive': 'rgba(216,222,233,0.35)', + '--d3-text-muted': 'rgba(216,222,233,0.25)', + '--d3-text-dimLabel': '#5e81ac', + '--d3-border-default': 'rgba(216,222,233,0.10)', + '--d3-border-subtle': 'rgba(216,222,233,0.06)', + '--d3-border-strong': 'rgba(216,222,233,0.16)', + '--d3-accent-main': '#88c0d0', + '--d3-accent-dim': 'rgba(136,192,208,0.08)', + '--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)', + '--d3-bg-result': '#434c5e', + '--d3-border-result': 'rgba(216,222,233,0.10)', + '--d3-text-result': 'rgba(236,239,244,0.87)', + '--d3-action-btn': 'rgba(216,222,233,0.4)', + '--d3-action-btn-hover-bg': 'rgba(216,222,233,0.08)', + '--d3-action-btn-hover': 'rgba(216,222,233,0.7)', + }, + solarized: { + '--d3-bg-card': '#073642', + '--d3-bg-tip': 'rgba(0,43,54,0.92)', + '--d3-text-primary': 'rgba(238,232,213,0.87)', + '--d3-text-secondary': 'rgba(147,161,161,0.7)', + '--d3-text-inactive': 'rgba(147,161,161,0.4)', + '--d3-text-muted': 'rgba(147,161,161,0.25)', + '--d3-text-dimLabel': '#7a6c2e', + '--d3-border-default': 'rgba(131,148,150,0.13)', + '--d3-border-subtle': 'rgba(131,148,150,0.08)', + '--d3-border-strong': 'rgba(131,148,150,0.20)', + '--d3-accent-main': '#b58900', + '--d3-accent-dim': 'rgba(181,137,0,0.08)', + '--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)', + '--d3-bg-result': '#0d4250', + '--d3-border-result': 'rgba(131,148,150,0.13)', + '--d3-text-result': 'rgba(238,232,213,0.87)', + '--d3-action-btn': 'rgba(147,161,161,0.4)', + '--d3-action-btn-hover-bg': 'rgba(131,148,150,0.10)', + '--d3-action-btn-hover': 'rgba(147,161,161,0.7)', + }, + catppuccin: { + '--d3-bg-card': '#313244', + '--d3-bg-tip': 'rgba(30,30,46,0.92)', + '--d3-text-primary': 'rgba(205,214,244,0.87)', + '--d3-text-secondary': 'rgba(186,194,222,0.7)', + '--d3-text-inactive': 'rgba(166,173,200,0.4)', + '--d3-text-muted': 'rgba(166,173,200,0.25)', + '--d3-text-dimLabel': '#585b70', + '--d3-border-default': 'rgba(205,214,244,0.08)', + '--d3-border-subtle': 'rgba(205,214,244,0.04)', + '--d3-border-strong': 'rgba(205,214,244,0.14)', + '--d3-accent-main': '#cba6f7', + '--d3-accent-dim': 'rgba(203,166,247,0.08)', + '--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)', + '--d3-bg-result': '#3a3a54', + '--d3-border-result': 'rgba(205,214,244,0.08)', + '--d3-text-result': 'rgba(205,214,244,0.87)', + '--d3-action-btn': 'rgba(166,173,200,0.4)', + '--d3-action-btn-hover-bg': 'rgba(205,214,244,0.06)', + '--d3-action-btn-hover': 'rgba(205,214,244,0.7)', + }, + dracula: { + '--d3-bg-card': '#44475a', + '--d3-bg-tip': 'rgba(40,42,54,0.92)', + '--d3-text-primary': 'rgba(248,248,242,0.87)', + '--d3-text-secondary': 'rgba(169,176,208,0.7)', + '--d3-text-inactive': 'rgba(169,176,208,0.4)', + '--d3-text-muted': 'rgba(169,176,208,0.25)', + '--d3-text-dimLabel': '#6272a4', + '--d3-border-default': 'rgba(248,248,242,0.08)', + '--d3-border-subtle': 'rgba(248,248,242,0.04)', + '--d3-border-strong': 'rgba(248,248,242,0.14)', + '--d3-accent-main': '#bd93f9', + '--d3-accent-dim': 'rgba(189,147,249,0.08)', + '--d3-shadow-popup': '0 8px 32px rgba(0,0,0,0.5)', + '--d3-bg-result': '#4f5266', + '--d3-border-result': 'rgba(248,248,242,0.08)', + '--d3-text-result': 'rgba(248,248,242,0.87)', + '--d3-action-btn': 'rgba(169,176,208,0.4)', + '--d3-action-btn-hover-bg': 'rgba(248,248,242,0.06)', + '--d3-action-btn-hover': 'rgba(248,248,242,0.7)', + }, +} + +/** + * 팝업용 CSS 변수 맵 조회. + * ThemeMode('system' | 'light' | 'dark') 또는 테마 키를 받아 해당 변수 맵 반환. + * 'system' 또는 알 수 없는 값은 'dark'로 폴백. + */ +export function getPopupThemeVars(themeKey: string): PopupThemeVars { + const key = themeKey as PopupThemeKey + return POPUP_THEME_VARS[key] ?? POPUP_THEME_VARS.dark +} + +/** + * 팝업 BrowserWindow에 insertCSS로 주입할 CSS 문자열 반환. + * `:root { --d3-bg-card: #242427; ... }` + */ +export function buildPopupThemeCss(themeKey: string): string { + const vars = getPopupThemeVars(themeKey) + const declarations = Object.entries(vars) + .map(([prop, value]) => ` ${prop}: ${value};`) + .join('\n') + return `:root {\n${declarations}\n}` +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 473894f..e0134b5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,7 +5,7 @@ // Common // ============================================================ -export type ThemeMode = 'light' | 'dark' | 'auto' +export type ThemeMode = 'light' | 'dark' | 'auto' | 'nord' | 'solarized' | 'catppuccin' | 'dracula' export type VoiceMode = 'dictation' | 'hands-free' @@ -267,7 +267,7 @@ export interface LLMModel { modifiedAt: string } -export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom' +export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom' | 'chain' export interface LLMProcessParams { text: string @@ -336,7 +336,7 @@ export interface SetEnabledParams { enabled: boolean } -export type HotkeyAction = 'dictation' | 'hands-free' | 'command' +export type HotkeyAction = 'dictation' | 'hands-free' | 'command' | 'caption' export interface HotkeyTriggeredEvent { action: HotkeyAction @@ -370,6 +370,8 @@ export interface AppConfig { dictationShortcut: HotkeyBinding handsFreeShortcut: HotkeyBinding commandShortcut: HotkeyBinding + /** 실시간 자막 토글 핫키 (Phase 10.1) */ + captionShortcut: HotkeyBinding hotkeyEnabled: boolean insertMethod: 'clipboard' | 'keyboard' autoInsert: boolean @@ -380,6 +382,8 @@ export interface AppConfig { agentModeEnabled: boolean /** 핸즈프리 모드 활성화 (토글) */ handsFreeEnabled: boolean + /** 스크린 컨텍스트 캡처 활성화 (Phase 10.2) */ + screenContextEnabled: boolean } export interface ConfigGetParams { @@ -662,3 +666,284 @@ export interface WeeklyStats { wordCount: number sessionCount: number } + +// ============================================================ +// Phase 10: Memo Tags (10.3) +// ============================================================ + +export interface MemoTag { + id: string + historyId: string + tag: string + createdAt: number +} + +export interface AddTagParams { + historyId: string + tag: string +} + +export interface RemoveTagParams { + historyId: string + tag: string +} + +export interface GetTagsParams { + historyId: string +} + +export interface SearchByTagParams { + tag: string + page: number + pageSize: number +} + +export interface ExportMemoParams { + format: 'markdown' + tag?: string + from?: string + to?: string +} + +export interface TagCount { + tag: string + count: number +} + +// ============================================================ +// Phase 10: Voice Commands (10.5) +// ============================================================ + +export type KeywordMatchMode = 'prefix' | 'suffix' | 'contains' + +export interface VoiceCommandKeyword { + keyword: string + matchMode: KeywordMatchMode +} + +export interface VoiceCommandRule { + id: string + instructionId: string + keywords: VoiceCommandKeyword[] + enabled: boolean + priority: number +} + +export interface VoiceCommandMatch { + matched: boolean + ruleId: string | null + instructionId: string | null + /** 키워드 제거 후 남은 텍스트 */ + cleanedText: string + /** 매칭된 키워드 */ + matchedKeyword: string | null +} + +export interface SetVoiceCommandKeywordsParams { + instructionId: string + keywords: VoiceCommandKeyword[] +} + +export interface SetVoiceCommandEnabledParams { + enabled: boolean +} + +// ============================================================ +// Phase 10: Screen Context (10.2) +// ============================================================ + +export interface ScreenContext { + appName: string | null + windowTitle: string | null + selectedText: string | null + capturedAt: number +} + +export interface CaptureContextResult { + context: ScreenContext + /** 선택 텍스트 캡처 시도 여부 */ + selectedTextAttempted: boolean +} + +// ============================================================ +// Phase 10: LLM Chain (10.4) +// ============================================================ + +export interface ChainStep { + instructionId: string + /** 이전 단계 결과 사용 or 원본 텍스트 사용 */ + inputSource: 'previous' | 'original' +} + +export interface LLMChain { + id: string + name: string + steps: ChainStep[] + createdAt: number + updatedAt: number +} + +export interface CreateChainParams { + name: string + steps: ChainStep[] +} + +export interface UpdateChainParams { + id: string + name?: string + steps?: ChainStep[] +} + +export interface DeleteChainParams { + id: string +} + +export interface ExecuteChainParams { + chainId: string + text: string +} + +export interface ChainProgress { + chainId: string + currentStep: number + totalSteps: number + stepName: string + intermediateText: string +} + +export interface ChainExecutionResult { + chainId: string + finalText: string + steps: Array<{ instructionId: string; output: string; durationMs: number }> + totalDurationMs: number +} + +// ============================================================ +// Phase 10: Live Caption (10.1) +// ============================================================ + +export type CaptionState = 'inactive' | 'starting' | 'active' | 'stopping' + +export interface CaptionSegment { + id: string + text: string + timestamp: number + isFinal: boolean +} + +export type CaptionAudioSource = 'mic' | 'system' | 'both' + +export interface CaptionConfig { + fontSize: number + opacity: number + maxLines: number + autoClearMs: number + /** 오디오 소스: 마이크 / 시스템 오디오 / 둘 다 */ + audioSource: CaptionAudioSource +} + +export interface CaptionSessionSummary { + sessionId: string + segments: CaptionSegment[] + startedAt: number + endedAt: number + totalDurationMs: number +} + +// ============================================================ +// Phase 11: License & Monetization +// ============================================================ + +/** 라이센스 티어 */ +export type LicenseTier = 'free' | 'pro' | 'pro_plus' + +/** 기능 게이팅 대상 */ +export enum Feature { + // 쿼터 제한 기능 (Free에서 횟수 제한) + DICTATION = 'dictation', + LLM_PROCESS = 'llm_process', + + // Pro 이상 + HISTORY_UNLIMITED = 'history_unlimited', + HISTORY_EXPORT = 'history_export', + CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create', + LIVE_CAPTION = 'live_caption', + SCREEN_CONTEXT = 'screen_context', + VOICE_MEMO = 'voice_memo', + VOICE_COMMAND = 'voice_command', + LLM_CHAIN = 'llm_chain', + + // Pro+ 이상 + FILE_TRANSCRIPTION = 'file_transcription', + VOICE_CONVERSATION = 'voice_conversation', + DICTATION_TEMPLATE = 'dictation_template', + MEETING_SUMMARY = 'meeting_summary', + LOCAL_RAG = 'local_rag', + OS_AUTOMATION = 'os_automation', +} + +/** 라이센스 정보 (electron-store에 저장) */ +export interface LicenseInfo { + tier: LicenseTier + licenseKey: string | null + activatedAt: number | null + machineId: string + /** 마지막 온라인 검증 시각 */ + lastVerifiedAt: number | null + /** 오프라인 유예 만료 (lastVerifiedAt + 30일) */ + offlineGraceUntil: number | null +} + +/** 일일 사용량 */ +export interface DailyUsage { + date: string // 'YYYY-MM-DD' + feature: string + count: number +} + +/** 쿼터 정보 */ +export interface UsageQuota { + feature: Feature + used: number + limit: number // -1 = 무제한 + remaining: number // -1 = 무제한 + resetAt: string // 다음 리셋 시각 (내일 00:00) ISO 8601 +} + +/** 기능 접근 결과 */ +export interface FeatureAccess { + allowed: boolean + reason: 'ok' | 'quota_exceeded' | 'tier_required' | 'license_expired' + requiredTier?: LicenseTier + quota?: UsageQuota +} + +/** 업그레이드 유도 이벤트 */ +export interface UpgradePromptEvent { + feature: Feature + reason: 'quota_exceeded' | 'tier_required' + currentTier: LicenseTier + requiredTier: LicenseTier + quota?: UsageQuota +} + +/** 라이센스 활성화 파라미터 */ +export interface ActivateLicenseParams { + licenseKey: string +} + +/** 라이센스 활성화 결과 */ +export interface ActivateLicenseResult { + success: boolean + tier: LicenseTier + message: string +} + +/** 티어별 기능 비교 항목 */ +export interface TierComparison { + feature: Feature + featureLabel: string + free: boolean | string + pro: boolean | string + proPlus: boolean | string +}