From d0c4f9ee539eb37ab667435a06504bd6f66f7e0a Mon Sep 17 00:00:00 2001
From: Yun Chan
Date: Wed, 8 Apr 2026 01:16:52 +0900
Subject: [PATCH] =?UTF-8?q?Phase=2014=20=EA=B5=AC=ED=98=84:=20=ED=9A=8C?=
=?UTF-8?q?=EC=9D=98=20=EB=AA=A8=EB=93=9C=20(Meeting=20Mode)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 회의록 + PDF/MD 내보내기.
CaptionService 연동, 동시 사용 충돌 방지, 긴 회의 2-pass 요약 지원.
DB 2테이블, IPC 15채널, UI 3뷰(목록/녹음/상세), 12개 locale i18n.
---
.claude/agents/ui-specialist.md | 47 +-
.claude/agents/voice-pipeline-expert.md | 19 +-
.claude/settings.json | 30 +-
.claude/skills/debug-electron/SKILL.md | 55 +-
.claude/skills/implement-phase/SKILL.md | 90 ++-
.claude/skills/review-phase/SKILL.md | 40 +-
.claude/skills/scaffold-component/SKILL.md | 48 +-
.claude/skills/test-and-commit/SKILL.md | 44 +-
CLAUDE.md | 366 ++-------
src/main/bootstrap.ts | 13 +
src/main/db/index.ts | 34 +
src/main/db/schema.ts | 50 ++
src/main/ipc/index.ts | 2 +
src/main/ipc/meeting-mode-handlers.ts | 115 +++
src/main/services/MeetingModeService.ts | 750 +++++++++++++++++++
src/main/services/VoiceModeService.ts | 6 +
src/preload/index.ts | 46 ++
src/renderer/components/AppLayout.tsx | 6 +-
src/renderer/components/SettingsModal.tsx | 16 +-
src/renderer/components/TemplateSection.tsx | 2 +-
src/renderer/components/ds/MetalDial.tsx | 8 +-
src/renderer/i18n/de.json | 67 +-
src/renderer/i18n/en.json | 80 +-
src/renderer/i18n/es.json | 67 +-
src/renderer/i18n/fr.json | 67 +-
src/renderer/i18n/ja.json | 67 +-
src/renderer/i18n/ko.json | 80 +-
src/renderer/i18n/pt.json | 67 +-
src/renderer/i18n/ru.json | 67 +-
src/renderer/i18n/th.json | 67 +-
src/renderer/i18n/vi.json | 67 +-
src/renderer/i18n/zh-TW.json | 67 +-
src/renderer/i18n/zh.json | 67 +-
src/renderer/pages/MeetingModePage.tsx | 570 ++++++++++++++
src/shared/errors.ts | 8 +
src/shared/ipc-channels.ts | 20 +
src/shared/types.ts | 96 +++
tests/main/services/VoiceModeService.test.ts | 8 +-
38 files changed, 2550 insertions(+), 769 deletions(-)
create mode 100644 src/main/ipc/meeting-mode-handlers.ts
create mode 100644 src/main/services/MeetingModeService.ts
create mode 100644 src/renderer/pages/MeetingModePage.tsx
diff --git a/.claude/agents/ui-specialist.md b/.claude/agents/ui-specialist.md
index c809c58..57ab0e6 100644
--- a/.claude/agents/ui-specialist.md
+++ b/.claude/agents/ui-specialist.md
@@ -12,36 +12,37 @@ tools:
# UI Specialist
-Speakly 렌더러 분석(781KB 번들)에서 추출한 UI 패턴을 적용.
+D3RO-VOICE의 "정밀 기기(Meteorological Instrument)" 디자인 정체성을 구현.
-## 컴포넌트 구조 (Speakly 패턴)
-```
-App (root)
- ├── ThemeProvider (MUI, light/dark/auto)
- ├── Drawer (240px 사이드바)
- │ ├── NavItems
- │ └── BottomBar
- └── Content Area
- ├── Dashboard (통계, 최근 세션)
- ├── History (검색, 재시도)
- ├── Dictionary (사전 관리)
- └── Settings (Modal)
-```
+## 디자인 토큰 체계 (SSOT)
+- **d3roPalette**: 색상 토큰 (theme.ts에서 export)
+- **d3roTypo**: 13단계 타이포 (hero 42px ~ nano 7px)
+- **d3roShadow**: 10종 그림자 (chassis/card/inset/button 등)
+- **d3roRadius**: 7종 반경 (outer 24px ~ pill 999px)
+- hex 리터럴 직접 사용 금지 (theme.ts 제외)
-## MUI 테마 (Speakly 참조)
-- primary: rgb(31, 93, 242)
-- borderRadius: 12
-- fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI"
-- textTransform: 'none'
-- WebkitAppRegion: 'drag' (타이틀바)
+## DS 컴포넌트 (src/renderer/components/ds/)
+- CrtDisplay: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치)
+- InstrumentPanel: 메탈 섀시 컨테이너
+- Led: LED 인디케이터 (amber/green/red, pulse)
+- PhysicalButton: 물리 버튼 (돌출 그림자, 눌림)
+- MetalCard: 메탈 카드 컨테이너
+- PhosphorText: 인광 텍스트 (13종 변형)
+- MetalDial: 메탈 다이얼 (드래그 회전)
+- ScreenPanel: 글래스 스크린 패널
+- ButtonGroup: 인셋 버튼 클러스터
## 녹음 UI 상태 머신
opening → recording → thinking → result/error/cancelled
- 9개 wave-bar, cos(n*PI/2) 분포 가중치
-- 100ms 간격 setInterval, smoothing: v += (S-v)*0.5
+- 100ms 간격, smoothing: v += (S-v)*0.5
- thinking: `min(95, (1 - 1/(1+1.5*t)) * 100)%` 점근 수렴
## 팝업 윈도우 (Vanilla JS)
-- 별도 HTML 엔트리포인트, React 미사용
+- 별도 HTML, React 미사용
- 2-phase 리사이즈: prepare → measured → show
-- CSS transition + transitionend + 200ms setTimeout 폴백
+- 앰버 악센트(#f25b29)
+
+## i18n
+- React: t() 함수 사용, 하드코딩 한국어/영어 금지
+- 리소스: src/renderer/locales/{ko,en}.json
diff --git a/.claude/agents/voice-pipeline-expert.md b/.claude/agents/voice-pipeline-expert.md
index 2bf0e7b..beac10d 100644
--- a/.claude/agents/voice-pipeline-expert.md
+++ b/.claude/agents/voice-pipeline-expert.md
@@ -28,16 +28,17 @@ AudioState: IDLE → INITIALIZING → STREAMING → STOPPED (별도 추적)
- 이중 조건 플러시: 모델 로딩 + 오디오 캡처 병렬
- `_tryFlushAll()`: 양쪽 조건 모두 true 시 flush
- 순서 보장: 설정 메시지 → 오디오 데이터
-- "오디오 손실 방지 > 컨텍스트 품질" 원칙
## 오디오 설정
-- sampleRate: 24000Hz → 16000Hz (Whisper 기본)
-- channels: 1 (mono)
-- noiseSuppression: false (초기 프레임 손실 방지)
-- echoCancellation: false (일방향 입력)
-- autoGainControl: false
+- sampleRate: 16000Hz (Whisper 기본)
+- channels: 1 (mono), bitDepth: 16 (PCM16)
+- noiseSuppression: false, echoCancellation: false, autoGainControl: false
## 로컬 STT 연동
-- Whisper에 PCM 16-bit 16kHz mono 직접 전달
-- Opus 인코딩 불필요 (로컬이므로)
-- faster-whisper Python sidecar 또는 whisper.cpp addon
+- PCM 16-bit 16kHz mono → faster-whisper Python sidecar (FastAPI HTTP)
+- Opus 인코딩 불필요 (로컬)
+
+## 타이밍 상수
+- 더블프레스: 300ms, 최소 오디오: 700ms
+- 녹음 후 대기: 4초 (버퍼 있으면 6초)
+- 완료 아이들 타임아웃: 30초, 절대 최대: 120초
diff --git a/.claude/settings.json b/.claude/settings.json
index 8a0a5fd..0bbe5e0 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -10,35 +10,26 @@
"Bash(git *)",
"Bash(ls *)",
"Bash(mkdir *)",
- "Bash(cat *)",
"Bash(which *)",
"Bash(python *)",
"Bash(pip *)",
"Bash(taskkill *)",
- "Bash(sleep *)",
"Bash(mv *)",
- "Bash(tail *)",
- "Bash(head *)",
- "Bash(grep *)",
- "Bash(wc *)",
+ "Bash(rm *)",
"Edit(src/**)",
"Edit(tests/**)",
"Edit(docs/**)",
+ "Edit(scripts/**)",
+ "Edit(.claude/**)",
"Edit(*.md)",
"Edit(*.json)",
- "Edit(*.ts)",
- "Edit(*.tsx)",
- "Edit(*.js)",
- "Edit(*.css)",
- "Edit(*.html)",
"Write(src/**)",
"Write(tests/**)",
"Write(docs/**)",
"Write(scripts/**)",
+ "Write(.claude/**)",
"Write(*.md)",
- "Write(*.json)",
- "Write(*.ts)",
- "Write(*.tsx)"
+ "Write(*.json)"
],
"deny": [
"Bash(rm -rf /)",
@@ -84,7 +75,16 @@
"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"
+ "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 '//' | grep -v 'var(' | head -3); if [ -n \"$HC\" ]; then echo \"[SSOT WARN] 하드코딩 hex 색상 — d3roPalette/CSS var 사용:\"; echo \"$HC\"; 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$' && ! echo \"$FILE\" | grep -qE 'locales/|i18n|test'; then I18N=$(python -c \"import re; lines=open('$FILE','r',encoding='utf-8').readlines(); [print(f'{i+1}:{l.rstrip()}') for i,l in enumerate(lines) if re.search(r'[\\uAC00-\\uD7AF]{2,}',l) and '//' not in l and 'import' not in l]\" 2>/dev/null | head -3); if [ -n \"$I18N\" ]; then echo \"[i18n WARN] 하드코딩 한국어 문자열 — t() 함수 사용 필요:\"; echo \"$I18N\"; fi; fi"
}
]
}
diff --git a/.claude/skills/debug-electron/SKILL.md b/.claude/skills/debug-electron/SKILL.md
index 720c87a..bf93e8d 100644
--- a/.claude/skills/debug-electron/SKILL.md
+++ b/.claude/skills/debug-electron/SKILL.md
@@ -1,29 +1,46 @@
---
name: debug-electron
description: Electron 앱 디버깅. IPC 문제, 메모리 누수, 렌더러 크래시 등 진단.
-allowed-tools: Read Grep Glob Bash
+allowed-tools: Read Grep Glob Bash Edit
---
# Electron Debug
-## 일반 디버깅
-1. 로그 확인 (`electron-log` 출력)
-2. IPC 채널 매칭 검증 (`shared/ipc-channels.ts` vs 실제 핸들러)
-3. 프리로드 스크립트 노출 API 확인
+증상 또는 에러 메시지를 받으면 아래 진단 흐름을 자동 실행합니다.
-## IPC 문제
-- `shared/ipc-channels.ts`에 채널 정의 확인
-- `src/main/ipc/`에 핸들러 등록 확인
-- `src/preload/index.ts`에 브릿지 노출 확인
-- 렌더러에서 `window.electronAPI.*` 호출 확인
+## 0. 자동 진단 파이프라인
-## 오디오 문제
-- Web Audio API 초기화 상태
-- 마이크 권한 확인
-- AudioContext sampleRate (24000Hz)
-- noiseSuppression: false 확인
+증상 분류 → 해당 섹션 실행 → 원인 보고 → 수정 제안
-## 메모리 누수
-- useEffect cleanup 함수 확인
-- IPC 이벤트 리스너 제거 확인
-- BrowserWindow destroy 확인
+1. **로그 수집**: `npm run dev` 출력 또는 electron-log 파일 확인
+2. **에러 분류**: IPC / 오디오 / 메모리 / 렌더러 크래시 / 빌드
+3. **해당 섹션 진단 실행**
+4. **원인 + 수정 방안 보고**
+
+## IPC 문제 진단
+
+자동 실행할 검증 체인:
+1. `shared/ipc-channels.ts`에서 문제 채널 Grep → 채널 정의 존재?
+2. `src/main/ipc/`에서 해당 채널 핸들러 Grep → `ipcMain.handle`/`on` 등록?
+3. `src/preload/index.ts`에서 `contextBridge.exposeInMainWorld` Grep → 브릿지 노출?
+4. `src/renderer/`에서 `window.electronAPI.*` 호출 Grep → 렌더러 사용?
+5. 1~4 중 끊어진 체인 지점이 원인
+
+## 오디오 문제 진단
+
+1. SoX 경로 확인: `src/main/utils/paths.ts` → `getSoxPath` 읽기
+2. AudioCaptureService 설정 확인: sampleRate 16000, noiseSuppression false
+3. faster-whisper sidecar 프로세스 상태: `tasklist | grep python`
+4. 마이크 권한: Electron `systemPreferences.getMediaAccessStatus('microphone')`
+
+## 메모리 누수 진단
+
+1. `useEffect` cleanup 누락 Grep: `useEffect` 있는데 return 없는 패턴
+2. IPC 리스너 제거 누락: `ipcRenderer.on` 대비 `removeListener`/`removeAllListeners` 검색
+3. BrowserWindow destroy 확인: `close` 이벤트에서 `destroy()` 호출 여부
+
+## 빌드/타입 문제
+
+1. `npm run typecheck 2>&1 | head -30` 실행
+2. 에러 파일/라인 추출 후 해당 코드 Read
+3. 원인 분석 후 수정 제안
diff --git a/.claude/skills/implement-phase/SKILL.md b/.claude/skills/implement-phase/SKILL.md
index 426eba7..f07d824 100644
--- a/.claude/skills/implement-phase/SKILL.md
+++ b/.claude/skills/implement-phase/SKILL.md
@@ -1,47 +1,65 @@
---
name: implement-phase
description: Speakly RE 노하우를 기반으로 D3RO-VOICE의 특정 페이즈를 구현. 페이즈 번호를 인자로 전달.
-argument-hint: "[phase number, e.g. 1]"
+argument-hint: "[phase number or feature name]"
allowed-tools: Read Write Edit Bash Glob Grep Agent
---
-# Phase Implementation
+# Feature / Phase Implementation
-지정된 페이즈를 구현합니다.
+페이즈 또는 새 기능을 구현합니다.
-## 실행 순서
+## 0. 사전 조건 가드 (스킵 불가)
-1. **설계서 로드** (필수, 반드시 먼저):
- - `docs/design/00-master-architecture.md` — 파일 트리, 초기화 순서, 서비스 의존관계
- - `docs/design/01-service-specifications.md` — 서비스 인터페이스, 상태 머신, 이벤트
- - `docs/design/02-ipc-and-types.md` — IPC 채널, TypeScript 타입, 에러 코드
- - `docs/design/03-db-and-ui.md` — DB 스키마, 컴포넌트 명세, MUI 테마
-2. **페이즈 문서 로드**: `docs/phases/phase-$ARGUMENTS.md` 읽기
-3. **RE 참조 문서 확인**: `docs/re-findings/` 에서 관련 노하우 확인
-4. **현재 코드 상태 파악**: 기존 구현된 코드 검토
-4. **구현 계획 수립**: TaskCreate로 세부 태스크 생성
-5. **구현**: 태스크별로 순차 구현
- - 타입 정의 (shared/) 먼저
- - 메인 프로세스 서비스 구현
- - IPC 핸들러 등록
- - 렌더러 UI 구현
- - 프리로드 브릿지 업데이트
-6. **테스트**: 각 모듈 단위 테스트
-7. **CLAUDE.md 업데이트**: 현재 상태 갱신
-8. **Memory 갱신** (필수): `project_status.md`에 완료된 Phase, 작업 내역, 기술 결정 기록
+구현 시작 전 반드시 아래를 확인하고, 실패 시 유저에게 보고 후 중단:
+
+1. **Git 클린 상태**: `git status --porcelain` — 커밋되지 않은 변경이 있으면 경고 후 커밋/스태시 제안
+2. **페이즈 문서 존재**: 페이즈 번호 전달 시 `docs/phases/phase-$ARGUMENTS.md` 파일 존재 확인. 없으면 중단
+3. **이전 페이즈 완료 확인**: `memory/project_status.md` 읽어서 선행 페이즈 완료 여부 확인. 미완료 시 경고
+4. **설계서 4종 존재 확인**: `docs/design/00~03` 파일 모두 존재 확인
+
+## 1. 설계서 로드 (필수)
+
+- `docs/design/00-master-architecture.md` — 아키텍처, 서비스 의존관계
+- `docs/design/01-service-specifications.md` — 서비스 인터페이스, 상태 머신
+- `docs/design/02-ipc-and-types.md` — IPC 채널, 타입, 에러 코드
+- `docs/design/03-db-and-ui.md` — DB 스키마, 컴포넌트 명세
+
+## 2. 요구사항 파악
+
+- 페이즈 번호 전달 시: `docs/phases/phase-$ARGUMENTS.md` 읽기
+- 피처명 전달 시: 유저 요구사항 분석 + 기존 코드 검토
+
+## 3. RE 참조
+`docs/re-findings/`에서 관련 Speakly 패턴 확인
+
+## 4. 현재 코드 파악
+기존 구현 검토, 영향 범위 분석
+
+## 5. 구현 (순서 중요)
+
+- shared/ 타입 정의
+- 메인 프로세스 서비스
+- IPC 핸들러
+- Preload 브릿지
+- 렌더러 UI (d3roPalette + DS 컴포넌트 활용)
+- i18n 키 추가 (ko/en)
+
+## 6. 검증
+`npm run typecheck` 통과
+
+## 7. 후속 워크플로우
+
+구현 완료 후 아래 순서로 유저에게 안내:
+1. `/review-phase $ARGUMENTS` 실행을 제안 (코드 리뷰)
+2. 리뷰 통과 시 `/test-and-commit` 실행을 제안 (테스트 + 커밋)
+3. `memory/project_status.md` 갱신 (즉시, 미루지 말 것)
## 구현 규칙 (위반 불가)
-- 서비스는 **설계서 01**의 인터페이스를 그대로 구현 (메서드 시그니처, 이벤트 페이로드 일치)
-- IPC 채널은 **설계서 02**의 `IPC_CHANNELS` 상수를 그대로 사용 (임의 채널명 금지)
-- 타입은 **설계서 02**의 `shared/types.ts` 정의를 그대로 사용 (임의 타입 금지)
-- 에러는 **설계서 02**의 `D3ROError` + `ErrorCode` enum 사용
-- DB는 **설계서 03**의 drizzle-orm 스키마 그대로 사용
-- UI는 **설계서 03**의 컴포넌트 Props/상태 명세 준수
-- 팝업은 **설계서 03**의 HTML/CSS/애니메이션 수학 공식 준수
-
-## 완료 조건
-- 모든 태스크 completed
-- `npm run typecheck` 통과
-- `npm run test` 통과
-- CLAUDE.md "현재 상태" 업데이트됨
-- Memory `project_status.md` 갱신됨 (Phase 완료 내역, 기술 결정, 다음 작업)
+- 서비스: 싱글톤 + EventEmitter, 설계서 01 인터페이스 준수
+- IPC: 설계서 02의 IPC_CHANNELS 상수 사용
+- 에러: D3ROError + ErrorCode enum
+- DB: drizzle-orm 스키마
+- UI: d3roPalette/d3roTypo/d3roShadow 토큰만 (hex 리터럴 금지)
+- i18n: t() 함수 (하드코딩 문자열 금지)
+- any 타입 절대 금지, console.log 절대 금지
diff --git a/.claude/skills/review-phase/SKILL.md b/.claude/skills/review-phase/SKILL.md
index 99dfd60..c1e03fa 100644
--- a/.claude/skills/review-phase/SKILL.md
+++ b/.claude/skills/review-phase/SKILL.md
@@ -1,27 +1,39 @@
---
name: review-phase
description: 완료된 페이즈를 검토하고 다음 페이즈 준비 상태를 판단. 페이즈 번호를 인자로 전달.
-argument-hint: "[phase number, e.g. 1]"
-allowed-tools: Read Glob Grep Bash Agent
+argument-hint: "[phase number or 'all' for full review]"
+allowed-tools: Read Edit Write Glob Grep Bash Agent
---
-# Phase Review
+# Code Review
-완료된 페이즈를 검토합니다.
+완료된 페이즈 또는 전체 코드베이스를 검토합니다.
## 검토 항목
-1. **요구사항 충족**: `docs/phases/phase-$ARGUMENTS.md` 대비 구현 완성도
-2. **Speakly 패턴 적용**: RE 노하우가 제대로 반영되었는지
- - 상태 머신 설계가 Speakly 수준인가?
- - 에러 처리가 포괄적인가?
- - 윈도우 관리가 프리로딩 + 2-phase 리사이즈인가?
-3. **코드 품질**: TypeScript strict, 일관된 네이밍, 중복 없음
-4. **테스트 커버리지**: 핵심 서비스에 단위 테스트 존재
+1. **요구사항 충족**: phase 문서 또는 유저 요구사항 대비 구현 완성도
+2. **Speakly 패턴 적용**:
+ - 상태 머신 (RecognitionState + AudioState 분리)
+ - 이중 조건 플러시
+ - 프리로딩 + 2-phase 리사이즈
+ - 에러 처리 포괄성
+3. **SSOT 준수** (Grep 도구 사용):
+ - hex 하드코딩: `src/renderer/` 내 `.tsx`/`.ts`에서 `'#[0-9a-fA-F]{6}'` 검색 (theme.ts 제외 0건이어야 함)
+ - i18n: `.tsx` 파일에서 한국어(`[\uAC00-\uD7AF]`) 또는 JSX 내 영어 문자열 리터럴 검색
+ - CSS var 폴백 hex는 허용 (예: `var(--color, #fff)`)
+4. **코드 품질**: TypeScript strict, any 없음, console.log 없음
5. **타입 안전성**: `npm run typecheck` 통과
6. **빌드**: `npm run build` 통과
+## 자동 수정
+
+리뷰 중 발견된 minor 이슈는 유저 확인 후 직접 수정 가능 (Edit/Write 권한 있음):
+- CLAUDE.md 서비스 목록/현재 상태 갱신
+- memory/project_status.md 갱신
+
+critical/major 이슈는 수정하지 말고 보고만 할 것.
+
## 출력
-- 발견된 이슈 목록 (심각도: critical/major/minor)
-- 다음 페이즈 진행 가능 여부 (GO/NO-GO)
-- CLAUDE.md 업데이트 제안
+- 이슈 목록 (심각도: critical/major/minor)
+- 진행 가능 여부 (GO/NO-GO)
+- GO일 경우 `/test-and-commit` 실행을 제안
diff --git a/.claude/skills/scaffold-component/SKILL.md b/.claude/skills/scaffold-component/SKILL.md
index dbab035..ed9795b 100644
--- a/.claude/skills/scaffold-component/SKILL.md
+++ b/.claude/skills/scaffold-component/SKILL.md
@@ -2,30 +2,54 @@
name: scaffold-component
description: D3RO-VOICE 프로젝트 컨벤션에 맞게 새 컴포넌트/서비스를 스캐폴딩. 이름을 인자로 전달.
argument-hint: "[component or service name]"
-allowed-tools: Read Write Glob
+allowed-tools: Read Write Glob Grep Edit
---
# Scaffold
프로젝트 컨벤션에 맞는 새 모듈을 생성합니다.
+모듈명: $ARGUMENTS
+
+## 0. 중복 방지 가드 (스킵 불가)
+
+생성 전 반드시 확인:
+1. **서비스**: `src/main/services/` 내 동일 이름 파일 Glob 검색. 존재 시 유저에게 확인
+2. **컴포넌트**: `src/renderer/components/` 내 동일 이름 Glob 검색. 존재 시 유저에게 확인
+3. **IPC 채널**: `shared/ipc-channels.ts`에서 해당 feature 네임스페이스 Grep 검색. 충돌 시 경고
+4. **i18n 키**: `src/renderer/locales/ko.json`에서 해당 키 접두사 검색. 충돌 시 경고
+
## 서비스 스캐폴딩 (메인 프로세스)
-Speakly 패턴 적용:
+
+Speakly 패턴:
- 싱글톤 + EventEmitter
-- 로거 태그 `[ServiceName]`
-- 초기화/종료 메서드
+- 로거 태그 `[ServiceName]` (electron-log, console.log 금지)
+- 초기화/종료 메서드 (`initialize()` / `dispose()`)
- IPC 채널 `shared/ipc-channels.ts`에 추가
+- 에러: D3ROError + ErrorCode enum
+
+생성 파일:
+- `src/main/services/$ARGUMENTS.ts` — 서비스 본체
+- `shared/ipc-channels.ts` — 채널 추가 (Edit)
+- `src/main/ipc/` — 핸들러 등록 (Edit)
## React 컴포넌트 스캐폴딩 (렌더러)
+
- 함수형 컴포넌트 + TypeScript Props 인터페이스
-- MUI sx prop 기반 스타일링
-- electronAPI IPC 호출 패턴
+- 스타일: d3roPalette/d3roTypo/d3roShadow 토큰만 (hex 리터럴 금지)
+- DS 컴포넌트(MetalCard, PhosphorText, Led, PhysicalButton 등) 적극 활용
+- i18n: t() 함수 사용, 하드코딩 문자열 금지
+- IPC: window.electronAPI.* 호출
+
+생성 파일:
+- `src/renderer/components/$ARGUMENTS.tsx` — 컴포넌트 본체
+- `src/renderer/locales/ko.json` — i18n 키 추가 (Edit)
+- `src/renderer/locales/en.json` — i18n 키 추가 (Edit)
## Vanilla JS 팝업 스캐폴딩
-Speakly 패턴:
-- 별도 HTML 엔트리포인트
-- 순수 DOM 조작 (React 미사용)
-- 2-phase 리사이즈: prepare(측정) → resize → show
-- mouseenter/leave 이벤트 메인 프로세스 전달
-모듈명: $ARGUMENTS
+Speakly 패턴:
+- 별도 HTML 엔트리포인트, React 미사용
+- 순수 DOM 조작
+- 2-phase 리사이즈: prepare(측정) → resize → show
+- d3roPalette 토큰의 CSS var 사용
diff --git a/.claude/skills/test-and-commit/SKILL.md b/.claude/skills/test-and-commit/SKILL.md
index 917d4c3..0a294d9 100644
--- a/.claude/skills/test-and-commit/SKILL.md
+++ b/.claude/skills/test-and-commit/SKILL.md
@@ -1,32 +1,34 @@
---
name: test-and-commit
description: 테스트 실행 후 통과하면 커밋. 코드 변경 완료 후 사용.
-allowed-tools: Bash Read
+allowed-tools: Bash Read Edit Write Glob Grep
---
# Test & Commit
-1. **타입 체크**
- ```bash
- npm run typecheck
- ```
+## 1. 검증 파이프라인 (순서대로)
-2. **린트**
- ```bash
- npm run lint
- ```
+```bash
+npm run typecheck
+npm run lint
+npm run test
+```
-3. **테스트**
- ```bash
- npm run test
- ```
+## 2. 실패 시 자동 수정
-4. **모두 통과 시 커밋**
- - git status로 변경 파일 확인
- - git diff로 변경 내용 확인
- - 적절한 커밋 메시지 작성 (한글, Conventional Commits)
- - Co-Authored-By 절대 추가 금지
+- 에러 메시지를 분석하고 원인 파일을 찾아 수정 (Edit/Write 사용)
+- 수정 후 실패한 단계부터 재실행
+- 최대 3회 재시도. 3회 초과 시 유저에게 보고 후 중단
+- 수정 범위는 에러를 고치는 최소한으로 제한 (리팩토링 금지)
-5. **실패 시**
- - 에러 내용 분석
- - 수정 후 재시도
+## 3. 모두 통과 시 커밋
+
+- `git status`로 변경 파일 확인
+- `git diff`로 변경 내용 확인
+- `git log --oneline -5`로 기존 커밋 스타일 확인
+- 적절한 커밋 메시지 작성 (한글, Conventional Commits)
+- Co-Authored-By, Claude 관련 문구 절대 추가 금지
+
+## 4. 커밋 후
+
+- `memory/project_status.md` 갱신 필요 여부 유저에게 확인
diff --git a/CLAUDE.md b/CLAUDE.md
index 93bd17e..db56339 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,339 +1,59 @@
# D3RO-VOICE — 로컬 AI 음성 어시스턴트
-Genspark Speakly를 리버스엔지니어링하여 얻은 노하우를 기반으로 만드는 **완전 로컬** 음성 어시스턴트.
-클라우드 의존성 없이 Ollama + Whisper + TTS를 사용한다.
+Speakly RE 기반 완전 로컬 음성 어시스턴트. Ollama + Whisper + TTS.
-## 기술 스택 (고정)
-- **Framework**: Electron 33+ (contextIsolation: true, nodeIntegration: false)
-- **Frontend**: React 19 + MUI 7 + Vite
-- **Language**: TypeScript 5.7+ (strict mode)
-- **DB**: better-sqlite3 + drizzle-orm
-- **STT**: faster-whisper (Python sidecar) 또는 whisper.cpp
-- **TTS**: piper-tts 또는 kokoro
-- **LLM**: Ollama REST API (localhost:11434)
-- **Audio**: Web Audio API (renderer) + node-record-lpcm16 (main)
-- **Hotkey**: uiohook-napi (글로벌 키보드 후킹)
-- **Text Insert**: @nut-tree/nut-js (클립보드 + Ctrl+V)
-- **Config**: electron-store
+## 기술 스택
+Electron 33+ | React 19 + MUI 7 + Vite | TypeScript 5.7+ strict
+better-sqlite3 + drizzle-orm | faster-whisper (Python sidecar) | Ollama REST
+uiohook-napi | @nut-tree-fork/nut-js | electron-store | electron-log
-## 프로젝트 구조
-```
-D3ROVoice/
-├── src/
-│ ├── main/ # Electron 메인 프로세스
-│ │ ├── services/ # STT, TTS, LLM, Audio, Hotkey, TextInsert
-│ │ ├── windows/ # 윈도우 매니저 (팁, 팝업, 메인)
-│ │ ├── ipc/ # IPC 핸들러 등록
-│ │ ├── db/ # SQLite (history, dictionary)
-│ │ └── index.ts # 앱 진입점
-│ ├── renderer/ # React 앱
-│ │ ├── components/ # React 컴포넌트
-│ │ ├── popups/ # Vanilla JS 팝업 (recording-tip, result-popup)
-│ │ └── main.tsx # 렌더러 진입점
-│ ├── preload/ # contextBridge IPC 브릿지
-│ │ └── index.ts
-│ └── shared/ # 공유 타입, IPC 채널 정의, 에러 코드
-│ ├── ipc-channels.ts
-│ ├── types.ts
-│ └── errors.ts
-├── docs/ # 설계 문서
-│ ├── phases/ # 페이즈별 요구사항
-│ └── re-findings/ # Speakly 리버스엔지니어링 결과
-├── scripts/ # 빌드/유틸 스크립트
-└── tests/
-```
-
-## 빌드 & 실행
+## 빌드
```bash
-npm run dev # Electron + Vite dev server
-npm run build # 프로덕션 빌드
-npm run test # vitest 테스트
-npm run lint # eslint + prettier
-npm run typecheck # tsc --noEmit
+npm run dev # Electron + Vite dev
+npm run build # 프로덕션 빌드
+npm run test # vitest
+npm run typecheck # tsc --noEmit
```
## 코딩 규칙
-- TypeScript strict mode 필수 (noImplicitAny, strictNullChecks)
-- 함수형 React 컴포넌트 + hooks만 사용
-- 메인 앱 = React + MUI, 팝업 윈도우 = Vanilla JS (Speakly 패턴)
-- IPC 채널명: `${feature}:${action}` (예: `voice:startRecording`, `config:getLanguage`)
-- 에러 처리: NXError 패턴 (에러 코드 + 메시지)
-- 로거: electron-log 사용, console.log 금지
+- strict mode, any 금지, console.log 금지 (logger 사용)
+- 서비스: 싱글톤 + EventEmitter
+- IPC: `${feature}:${action}`, D3ROError + ErrorCode
+- 메인앱=React+MUI, 팝업=Vanilla JS
+- 테마 SSOT: d3roPalette/d3roShadow만 사용, hex 하드코딩 금지 (theme.ts 제외)
+- i18n: t() 함수, 하드코딩 한국어/영어 금지
+- Co-Authored-By, Claude 관련 커밋 문구 금지
-## 절대 하지 말 것
-- any 타입 사용 금지
-- renderer에서 Node.js API 직접 import 금지
-- electron의 remote 모듈 사용 금지
-- console.log 남기지 말 것 (logger 사용)
-- 하드코딩된 시크릿/비밀번호 금지
-- Co-Authored-By, Claude 관련 문구 커밋 메시지에 추가 금지
-
-## Speakly에서 채택한 핵심 패턴
-1. **상태 머신**: RecognitionState + AudioState 분리 추적
-2. **이중 조건 플러시**: 모델 로딩 + 오디오 버퍼링 동시 진행, 둘 다 준비 시 플러시
-3. **텍스트 삽입**: 클립보드 save → set → Ctrl+V → restore
-4. **윈도우 관리**: 프리로딩 + 2-phase 리사이즈 (측정→resize→show)
-5. **팝업**: 메인 앱=React, 경량 팝업=Vanilla JS
-6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
+## 핵심 패턴 (Speakly 차용)
+- RecognitionState(9) + AudioState(4) 이중 상태머신
+- 이중 조건 플러시 (모델 로딩 + 오디오 버퍼링 병렬)
+- 텍스트 삽입: clipboard save→set→Ctrl+V→restore
+- 윈도우: 프리로딩 + 2-phase 리사이즈
+- 녹음 UI: 9개 웨이브 바, cos 분포, 100ms
## 현재 상태
-Phase: 13 전체 완료 + 랜딩 페이지 완성 (Phase 1~13 + site/)
-마지막 작업: 랜딩 페이지 디자인 통합 + 10개 국어 i18n
-다음 작업: 빌드/배포 안정화, E2E 테스트, 또는 새 피처
+Phase 1~14 전체 완료 + 랜딩 페이지(site/).
차단 이슈: @nut-tree-fork/nut-js 포크 사용
-### 랜딩 페이지 (site/)
-- Vite + React 19 + TypeScript + Tailwind CSS
-- 4가지 디자인 레퍼런스 통합 (KKG 3종 + Meteorological → D3RO 인더스트리얼)
-- SSOT: tokens.ts + tailwind.config.js 토큰 중앙 관리
-- 재사용 컴포넌트 8개: Badge/Container/Crosshair/DataPoint/GlowButton/InstrumentCard/Led/SectionHeader
-- CSS 이펙트: CRT scanline, crosshair, noise texture, glow button (WebGL 없음)
-- i18n 10개 국어: en/ko/ja/zh/es/fr/de/pt/ru/vi (Context+Hook, LanguageSwitcher, CJK line-height 보정)
-- GitHub Pages 자동 배포: .github/workflows/deploy-site.yml
+## 설계 문서 (구현 시 Read 도구로 참조)
+- docs/design/00-master-architecture.md — 시스템 아키텍처, 초기화/종료, 서비스 목록
+- docs/design/01-service-specifications.md — 서비스 인터페이스, 상태머신, 이벤트
+- docs/design/02-ipc-and-types.md — IPC 채널 전체, 타입, 에러코드, preload API
+- docs/design/03-db-and-ui.md — DB 스키마, React 컴포넌트, 팝업 명세, MUI 테마
-### DS 컴포넌트 현황 (src/renderer/components/ds/)
-- CrtDisplay.tsx: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치) ✅
-- InstrumentPanel.tsx: 메탈 섀시 컨테이너 (각인 텍스트, 노이즈) ✅
-- Led.tsx: LED 인디케이터 (amber/green/red/orange, pulse) ✅
-- PhysicalButton.tsx: 물리 버튼 (돌출 그림자, 눌림 피드백) ✅
-- MetalCard.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) ✅
+## 서비스 목록 (src/main/services/)
+Core: LoggerService, ConfigService, VoiceModeService(오케스트레이터)
+Audio: AudioCaptureService, LocalSTTService, LocalLLMService, TTSPlaybackService
+Input: HotkeyService, TextInsertService, SoundEffectService, AutoLaunchService
+Data: HistoryService, DictionaryService, CustomInstructionService, LicenseService
+Phase10+: MemoService, VoiceCommandService, ScreenContextService, ChainService, CaptionService
+Phase12+: FileTranscriptionService, MeetingSummaryService, DictationTemplateService
+Phase13+: VoiceConversationService, RAGService, VoiceActionService
+Phase14: MeetingModeService
-### 디자인 토큰 현황 (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)
+## DS 컴포넌트 (src/renderer/components/ds/)
+CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard,
+PhosphorText(13변형), MetalDial, ScreenPanel, ButtonGroup
-### Phase 13.2 구현 내용 (로컬 RAG)
-- **RAGService**: Ollama nomic-embed-text 임베딩, SQLite에 JSON 벡터 저장, 코사인 유사도 검색
-- **문서 관리**: .txt/.md/.pdf/.docx 지원, 500자 청크 분할 (50자 오버랩), 비동기 인덱싱
-- **벡터 검색**: 쿼리 임베딩 → 전체 청크 코사인 유사도 → topK 결과 → LLM 컨텍스트 주입
-- **KnowledgeBasePage**: 전용 UI (문서 목록, 인덱싱 진행률, 질문 입력, 답변+참조 소스 표시)
-- **AppLayout**: 'knowledge' 라우트 + RAG 네비 항목
-- **DB 테이블**: rag_documents + rag_chunks (Phase 13.2 신규)
-- **IPC**: RAG 네임스페이스 9채널
-- **ErrorCode**: 870-874 (DocumentNotFound, IndexingFailed, EmbeddingFailed, QueryFailed, UnsupportedFormat)
-
-### Phase 13.3 구현 내용 (OS 자동화)
-- **VoiceActionService**: 음성→LLM이 JSON 액션 플랜 생성→실행, 프리셋 6개 (크롬/메모장/탐색기/볼륨)
-- **액션 타입**: open_app, open_url, open_file, keyboard_shortcut, type_text, system_command
-- **안전장치**: 위험 명령어 블랙리스트 (rm, del, format, shutdown 등), safe=false 시 차단
-- **IPC**: VOICE_ACTION 네임스페이스 9채널
-- **ErrorCode**: 875-878 (PlanFailed, ExecutionFailed, Blocked, InvalidPlan)
-
-### Phase 13.1 구현 내용 (음성 대화 모드)
-- **VoiceConversationService**: STT→LLM→TTS 대화 루프 오케스트레이션, 세션 관리, 대화 히스토리 최근 10턴
-- **TTSPlaybackService**: Windows SAPI (PowerShell System.Speech) 기반 로컬 TTS, 문장 단위 큐 재생
-- **LocalLLMService.chatStream**: Ollama /api/chat 스트리밍, 대화 히스토리 messages 배열 전달
-- **VoiceConversationPage**: 전용 대화 UI (채팅 메시지 목록, 녹음/정지/취소 버튼, 텍스트 입력)
-- **AppLayout**: 'conversation' 라우트 + TALK 네비게이션 항목 추가
-- **IPC**: VOICE_CONVERSATION 네임스페이스 15채널 (startSession, sendMessage, assistantDelta 등)
-- **Preload**: voiceConversation API 네임스페이스 (17메서드/이벤트)
-- **ErrorCode**: 795-798 (ConversationSessionAlreadyActive, TTSFailed, LLMFailed 등)
-- **i18n**: conversation.* 번역 키 추가 (ko/en)
-
-### Phase 12 구현 내용 (Pro+ 피처)
-- **FileTranscriptionService**: ffmpeg(fluent-ffmpeg + @ffmpeg-installer/ffmpeg)로 미디어→PCM 변환, 30초 청크 분할 STT, 진행률 이벤트, 히스토리 자동 저장 (mode: 'file-transcription')
-- **MeetingSummaryService**: CaptionService session-saved 이벤트 → Ollama 자동 요약, 마크다운 파싱 (요약/결정/할일), history.summaryText 컬럼 저장, 마크다운 내보내기
-- **DictationTemplateService**: 템플릿 CRUD (electron-store), 세션 상태 머신 (idle→field-prompting→field-recording→completing), 프리셋 3개 (이메일/회의록/보고서), 출력 포맷 mustache 치환
-- **FileDropZone UI**: DashboardPage 드래그앤드롭 존, 진행률/결과/에러 상태 표시
-- **HistoryEntryCard 확장**: 요약 배지(Chip), 확장 뷰(요약/결정/할일), 요약 생성/내보내기 버튼
-- **TemplateSection UI**: CommandsPage 하단 섹션, 템플릿 목록/생성/편집/삭제/세션 시작
-- **DB 마이그레이션**: history 테이블 summary_text 컬럼 추가 (ALTER TABLE), mode enum에 'file-transcription' 추가
-- **IPC**: FILE_TRANSCRIPTION(6채널) + MEETING_SUMMARY(5채널) + DICTATION_TEMPLATE(11채널) = 22개 신규 채널
-- **Preload**: fileTranscription/meetingSummary/dictationTemplate 3개 API 네임스페이스
-- **ErrorCode**: 780-794 범위 (FFmpegFailed, InvalidFormat, ChunkFailed, SummaryGenerationFailed, TemplateNotFound 등)
-- **i18n**: fileTranscription.*/meetingSummary.*/template.* 번역 키 추가 (ko/en)
-
-### 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 버전, 기술 스택, 프로젝트 링크, 정밀기기 디자인
-- **녹음 WAV 저장**: VoiceModeService 세션 완료 시 WAV 파일 저장 + HistoryService audioLocalPath 연동
-- **마이크 테스트 UI**: Settings Audio 탭 레벨 바 (3초 캡처, audio-level 이벤트)
-- **온보딩 플로우**: 4단계 위저드 (환영→마이크 설정→핫키 설정→Ollama 설정→완료)
-- **MetalDial 컴포넌트**: 동심원 텍스처, 드래그 회전, 금속 광택 — 시안 A 핵심 요소 완성
-- **오디오 디바이스 선택**: AudioEndpoint + 디바이스 캐싱, Settings에서 실시간 전환
-- **Commands CRUD IPC 연결**: instruction:* 핸들러 완전 연동 + 활성 명령어 선택 UI
-- **Ctrl+Shift+C 커맨드 팝업**: 커서 위치에 명령어 선택 팝업, 키보드 탐색 (Arrow/Enter/ESC)
-- **Dictionary 편집**: 인라인 편집 + STT initialPrompt에 사전 단어 주입
-- **Ollama 설치 안내**: OllamaGuideModal + 넛징 버블 (Ollama 미실행 시 자동 표시)
-- **실시간 UI 갱신**: app:dataChanged IPC 이벤트 → Dashboard/History/Dictionary 자동 리프레시
-- **Settings 속도 개선**: 비동기 디바이스 로드 + 부팅 시 프리캐싱으로 모달 열기 지연 제거
-
-### Phase 8 구현 내용
-- **SSOT 완료**: d3roPalette에 11개 토큰 추가 (sidebar, chassis, inactive, dimLabel 등)
-- **매직넘버 제거**: tsx/ts 파일 매직넘버 0개 달성 (theme.ts 제외)
-- **DS 컴포넌트 SSOT**: 6개 모두 d3roPalette 참조로 교체
-- **HotkeyRecordModal**: 신규 — 커스텀 핫키 녹화 모달 (키 조합 입력→Chip 표시→저장)
-- **SettingsModal 재작성**: 음성 모드 3개(받아쓰기/Agent/원터치) 토글+핫키 표시+변경 UI
-- **DashboardPage 재작성**: 기능 중심 — Hero(핫키 표시) + 통계 4카드 + CRT 서비스 상태 + 최근 히스토리(날짜 그룹핑)
-- **recording-tip 수정**: 웨이브 바/프로그레스 바 색상 #1F5DF2(파란)→#f25b29(앰버)
-- **페이지 SSOT**: AppLayout, StatusBar, HistoryPage, DictionaryPage, CommandsPage 모두 완료
-- **설계 문서**: docs/phases/phase-8.md, docs/re-findings/speakly-settings-ui.md 작성
-
-### Phase 7.5 구현 내용
-- SoundEffectService: WAV 프리로드, fire-and-forget, PowerShell SoundPlayer로 재생
-- AutoLaunchService: app.setLoginItemSettings, ConfigService 연동, syncWithConfig
-- TextInsertService: 간이 삽입 검증 (EditMonitor 경량), 클립보드 확인
-- VoiceModeService 효과음 연동: session-started(start), completed(stop), cancelled(cancel), error(error)
-- IPC: system:playSound/setSoundEnabled/isSoundEnabled, config:setAutoLaunch/setCloseToTray
-- Bootstrap: sound-effects, auto-launch 초기화 단계 추가
-- 효과음 WAV 파일: scripts/generate-sounds.js로 생성 (recording-start/stop, error)
-
-### Phase 7 구현 내용
-- vitest 테스트 환경: vitest.config.ts, tests/setup.ts (electron/electron-log 모킹)
-- 테스트 헬퍼: tests/helpers/createTestDb.ts (in-memory SQLite + drizzle)
-- 단위 테스트 41개: HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError
-- electron-builder: electron-builder.yml (NSIS, asarUnpack, extraResources)
-- GitLab CI/CD: .gitlab-ci.yml (lint, typecheck, test, build, release)
-- 빌드 스크립트: pack, dist, test:unit 추가
-- 참고: better-sqlite3는 Electron용 빌드라 vitest에서 직접 사용 불가 → DB 서비스는 모킹 테스트
-- 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드 스크립트, 경로 해상도 유틸
-- src/main/utils/paths.ts: dev vs production 경로 자동 감지 (SoX, sidecar, sounds)
-- AudioCaptureService: 번들 SoX 경로 사용 (getSoxPath)
-- LocalSTTService: 번들 sidecar 경로 사용 (getSidecarCommand)
-- scripts/download-sox.ps1: SoX Windows 바이너리 다운로드 → resources/sox/
-- scripts/build-sidecar.py: PyInstaller → sidecar-dist/sidecar/sidecar.exe
-
-### Phase 6 구현 내용
-- CustomInstructionService: electron-store 기반, 프리셋 5개 (번역/요약/전문리라이트/코드설명/자유프롬프트)
-- 커스텀 명령어 CRUD: create/update/delete/reorder, 프리셋 보호 (삭제 불가)
-- CommandsPage: React MUI 명령어 목록 + 추가/편집 다이얼로그
-- i18n: ko.json/en.json 리소스, t() 함수, React 컨텍스트 (useI18n)
-- IPC: instruction:getAll/getById/create/update/delete/reorder 핸들러
-
-### Phase 3.5 구현 내용
-- HistoryPopup: Vanilla JS 팝업, 다크 카드(#242427), 앰버 악센트(#f25b29)
-- Ctrl+Shift+V 글로벌 단축키 → 커서 위치에 최근 10건 히스토리 팝업
-- Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
-- focusable: false → 활성 앱 포커스 유지
-- 2-phase 리사이즈, 등장/퇴장 애니메이션 (0.15s/0.1s)
-
-### Phase 5 구현 내용
-- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats 테이블, WAL 모드)
-- HistoryService: CRUD + 검색 + 통계 + 보존 정책(30일), 세션 완료 시 자동 이력 저장
-- DictionaryService: CRUD + 검색 + 사용 횟수 추적 + STT 프롬프트 힌트
-- History UI: 목록 + 검색 + 삭제 + 복사 + 페이지네이션
-- Dictionary UI: 목록 + 검색 + 추가 다이얼로그 + 삭제
-- Dashboard: 실데이터 통계 (총/오늘 세션수, 시간, 단어수, 연속일수)
-- IPC: history, dictionary, stats 핸들러 + preload API
-- Bootstrap: DB 초기화 단계 추가 (critical)
-
-### Phase 4 구현 내용
-- LocalLLMService: Ollama REST API 연동, 스트리밍 NDJSON 파싱, 가용성 폴링(5초)
-- 시스템 프롬프트: refine/translate/summarize/grammar/expand/custom 6개 액션
-- VoiceModeService LLM 연동: 전사→LLM 후처리→텍스트 삽입, LLM 실패 시 원본 폴백
-- LLM IPC 핸들러: llm:getStatus/getModels/setModel/process/cancelProcess/getServerUrl/setServerUrl
-- StatusBar: Ollama 연결 상태 + 활성 모델 표시
-- Bootstrap: LLM 가용성 폴링 초기화 단계 추가
-
-### Phase 3 구현 내용
-- TextInsertService: clipboard save→set→Ctrl+V→restore (@nut-tree-fork/nut-js, lazy dynamic import)
-- RecordingTip 팝업: Vanilla JS, 9개 웨이브바 cos분포 가중치, thinking 점근수렴, 2-phase 리사이즈
-- ResultPopup 팝업: Vanilla JS, 복사 버튼, auto-close(5초), 마우스 호버 유지, 다크모드
-- WindowManager 리팩토링: 팝업 프리로딩, 커서 위치 표시, 멀티모니터 보정
-- Settings 모달: General/Audio/STT/LLM 탭, 실시간 설정 변경
-- VoiceModeService 연동: 전사 완료 시 자동 텍스트 삽입 + RecordingTip→ResultPopup 전환
-- Bootstrap: 9단계 초기화 (popup-preload 추가)
-- electron-vite: 팝업 HTML 멀티 엔트리 + popup preload 빌드
-
-### Phase 2 구현 내용
-- AudioCaptureService: node-record-lpcm16 + SoX 실제 마이크 캡처 (PCM16 16kHz mono, 60ms 프레임, RMS 레벨)
-- HotkeyService: uiohook-napi 글로벌 키후킹, 더블프레스(300ms), holdMode/toggleMode, VK→uiohook 매핑
-- LocalSTTService: faster-whisper Python sidecar 관리, STTState 상태머신, 이중 조건 플러시, 자동 재시작(3회)
-- VoiceModeService: RecognitionState(9상태) + AudioState(4상태) 이중 상태머신, Action Queue 직렬화, accidentalPress(<700ms), Dictation/HandsFree 모드
-- Python sidecar: FastAPI (GET /health, POST /load, POST /transcribe, POST /shutdown)
-- IPC 핸들러: voice, stt, hotkey 추가 (기존 audio, config, window, system에 추가)
-- Preload: voice/stt/hotkey API 전체 노출
-- Bootstrap: 7단계 초기화 (logger→config→windows→tray→ipc→hotkey→voice-mode)
-
-### Phase 1 구현 내용
-- 프로젝트 초기화: package.json, TypeScript strict, electron-vite, ESLint, Prettier
-- shared 타입: ipc-channels.ts (113채널), types.ts, errors.ts (ErrorCode enum), constants.ts
-- 메인 프로세스: index.ts (단일 인스턴스), bootstrap.ts, lifecycle.ts
-- 서비스: LoggerService, ConfigService (ESM dynamic import)
-- Renderer: React 19 + MUI 7, AppLayout (Drawer 240px), DashboardPage
-- 시스템 트레이, closeToTray
-
-## 페이즈 로드맵
-- Phase 1: 프로젝트 초기화 + Electron 뼈대 + 마이크 캡처
-- Phase 2: 로컬 STT 연동 (Whisper) + 핫키
-- Phase 3: 텍스트 삽입 + 기본 UI (Dashboard, RecordingTip)
-- Phase 3.5: 커서 위치 히스토리 팝업 (D3RO 고유 기능)
-- Phase 4: Ollama LLM 연동 (텍스트 다듬기, 번역)
-- Phase 5: TTS + 히스토리/사전 DB
-- Phase 6: 커스텀 명령어 + 설정 UI 고도화
-- Phase 7: 테스트 + 빌드 + 배포
-- Phase 7.5: Speakly 패턴 보강 (SoundEffect, AutoLaunch, EditMonitor, 오디오 뮤트)
-- 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.1: Pro+ 프리미엄 — 음성 대화 모드 (STT→LLM→TTS 루프) ✅
-- Phase 13.2: Pro+ 프리미엄 — 로컬 RAG (문서 임베딩+검색) ✅
-- Phase 13.3: Pro+ 프리미엄 — OS 자동화 (음성→JSON 액션) ✅
-
-## 설계 문서 (구현 시 반드시 참조)
-@docs/design/00-master-architecture.md
-@docs/design/01-service-specifications.md
-@docs/design/02-ipc-and-types.md
-@docs/design/03-db-and-ui.md
-
-## 페이즈 문서
-@docs/phases/phase-1.md
-@docs/phases/phase-2.md
-@docs/phases/phase-3.md
-@docs/phases/phase-3.5.md
-@docs/phases/phase-4.md
-@docs/phases/phase-5.md
-@docs/phases/phase-6.md
-@docs/phases/phase-7.md
-@docs/phases/phase-7.5.md
-@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
-@docs/re-findings/voice-pipeline-patterns.md
-@docs/re-findings/text-insertion-patterns.md
-@docs/re-findings/ui-patterns.md
-@docs/re-findings/native-dll-patterns.md
+## 디자인 토큰 (theme.ts)
+d3roPalette(색상) | d3roTypo(13단계) | d3roShadow(10종) | d3roRadius(7종)
diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts
index f09ba3e..9dbaedb 100644
--- a/src/main/bootstrap.ts
+++ b/src/main/bootstrap.ts
@@ -58,6 +58,7 @@ export async function bootstrap(): Promise {
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
+ { name: 'meeting-mode', critical: false, fn: initMeetingMode },
]
for (const step of steps) {
@@ -273,6 +274,12 @@ async function initMeetingSummaryWiring(): Promise {
const summaryService = getMeetingSummaryService()
captionService.on('session-saved', (summary: { sessionId: string }) => {
+ // 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
+ const { getMeetingModeService } = require('./services/MeetingModeService') as { getMeetingModeService: () => { isMeetingModeActive: () => boolean } }
+ if (getMeetingModeService().isMeetingModeActive()) {
+ logger.info('Skipping auto meeting summary: meeting mode active')
+ return
+ }
summaryService.onCaptionSessionSaved(summary).catch((err) => {
logger.warn('Auto meeting summary failed:', err)
})
@@ -282,6 +289,12 @@ async function initMeetingSummaryWiring(): Promise {
}
}
+async function initMeetingMode(): Promise {
+ // MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
+ const { getMeetingModeService } = await import('./services/MeetingModeService')
+ getMeetingModeService()
+}
+
// ── HistoryPopup IPC 연동 ────────────────────────────
function setupHistoryPopupIPC(): void {
diff --git a/src/main/db/index.ts b/src/main/db/index.ts
index 807ee18..69279aa 100644
--- a/src/main/db/index.ts
+++ b/src/main/db/index.ts
@@ -129,6 +129,40 @@ export function initDatabase(): BetterSQLite3Database {
CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id);
`)
+ // Phase 14: Meeting Mode 테이블 생성
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS meeting_sessions (
+ id TEXT PRIMARY KEY,
+ title TEXT,
+ status TEXT NOT NULL DEFAULT 'recording',
+ started_at INTEGER NOT NULL,
+ ended_at INTEGER,
+ duration_ms INTEGER,
+ raw_transcript TEXT,
+ minutes_markdown TEXT,
+ minutes_json TEXT,
+ stt_model TEXT,
+ llm_model TEXT,
+ stt_latency_ms INTEGER,
+ llm_latency_ms INTEGER,
+ error_message TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_meeting_sessions_created_at ON meeting_sessions(created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status ON meeting_sessions(status);
+
+ CREATE TABLE IF NOT EXISTS meeting_memos (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ content TEXT NOT NULL,
+ timestamp_ms INTEGER NOT NULL,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_meeting_memos_session_id ON meeting_memos(session_id);
+ CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
+ `)
+
// Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션
try {
const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }>
diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts
index 83226fe..58f2920 100644
--- a/src/main/db/schema.ts
+++ b/src/main/db/schema.ts
@@ -152,6 +152,56 @@ export type NewRAGDocumentRow = typeof ragDocuments.$inferInsert
export type RAGChunkRow = typeof ragChunks.$inferSelect
export type NewRAGChunkRow = typeof ragChunks.$inferInsert
+// ── meetingSessions (Phase 14) ──────────────────────────
+export const meetingSessions = sqliteTable(
+ 'meeting_sessions',
+ {
+ id: text('id').primaryKey(),
+ title: text('title'),
+ status: text('status', {
+ enum: ['recording', 'processing', 'completed', 'error'],
+ }).notNull().default('recording'),
+ startedAt: integer('started_at').notNull(),
+ endedAt: integer('ended_at'),
+ durationMs: integer('duration_ms'),
+ rawTranscript: text('raw_transcript'),
+ minutesMarkdown: text('minutes_markdown'),
+ minutesJson: text('minutes_json'),
+ sttModel: text('stt_model'),
+ llmModel: text('llm_model'),
+ sttLatencyMs: integer('stt_latency_ms'),
+ llmLatencyMs: integer('llm_latency_ms'),
+ errorMessage: text('error_message'),
+ createdAt: integer('created_at').notNull(),
+ updatedAt: integer('updated_at').notNull(),
+ },
+ (table) => [
+ index('idx_meeting_sessions_created_at').on(table.createdAt),
+ index('idx_meeting_sessions_status').on(table.status),
+ ]
+)
+
+// ── meetingMemos (Phase 14) ─────────────────────────────
+export const meetingMemos = sqliteTable(
+ 'meeting_memos',
+ {
+ id: text('id').primaryKey(),
+ sessionId: text('session_id').notNull(),
+ content: text('content').notNull(),
+ timestampMs: integer('timestamp_ms').notNull(),
+ createdAt: integer('created_at').notNull(),
+ },
+ (table) => [
+ index('idx_meeting_memos_session_id').on(table.sessionId),
+ index('idx_meeting_memos_timestamp').on(table.timestampMs),
+ ]
+)
+
+export type MeetingSessionRow = typeof meetingSessions.$inferSelect
+export type NewMeetingSessionRow = typeof meetingSessions.$inferInsert
+export type MeetingMemoRow = typeof meetingMemos.$inferSelect
+export type NewMeetingMemoRow = typeof meetingMemos.$inferInsert
+
// ── 타입 추출 ────────────────────────────────────────────
export type History = typeof history.$inferSelect
export type NewHistory = typeof history.$inferInsert
diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts
index 3d6a316..bcdcc6a 100644
--- a/src/main/ipc/index.ts
+++ b/src/main/ipc/index.ts
@@ -23,6 +23,7 @@ import { registerTemplateHandlers } from './template-handlers'
import { registerVoiceConversationHandlers } from './voice-conversation-handlers'
import { registerRAGHandlers } from './rag-handlers'
import { registerVoiceActionHandlers } from './voice-action-handlers'
+import { registerMeetingModeHandlers } from './meeting-mode-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@@ -51,5 +52,6 @@ export function registerAllIpcHandlers(): void {
registerVoiceConversationHandlers()
registerRAGHandlers()
registerVoiceActionHandlers()
+ registerMeetingModeHandlers()
logger.info('All IPC handlers registered')
}
diff --git a/src/main/ipc/meeting-mode-handlers.ts b/src/main/ipc/meeting-mode-handlers.ts
new file mode 100644
index 0000000..260a8ba
--- /dev/null
+++ b/src/main/ipc/meeting-mode-handlers.ts
@@ -0,0 +1,115 @@
+// src/main/ipc/meeting-mode-handlers.ts
+// Phase 14: Meeting Mode IPC 핸들러
+
+import { ipcMain } from 'electron'
+import { IPC_CHANNELS } from '@shared/ipc-channels'
+import { ErrorCode, ipcSuccess, ipcError } from '@shared/errors'
+import { getMeetingModeService } from '../services/MeetingModeService'
+import { getLogger } from '../services/LoggerService'
+import type {
+ MeetingAddMemoParams,
+ MeetingGetSessionsParams,
+ MeetingGetSessionParams,
+ MeetingDeleteSessionParams,
+ MeetingExportParams,
+} from '@shared/types'
+
+const logger = getLogger('meeting-mode-handlers')
+
+export function registerMeetingModeHandlers(): void {
+ const ch = IPC_CHANNELS.MEETING_MODE
+
+ ipcMain.handle(ch.START_RECORDING, async () => {
+ try {
+ const result = await getMeetingModeService().startRecording()
+ return ipcSuccess(result)
+ } catch (err) {
+ logger.error(`startRecording 실패: ${err instanceof Error ? err.message : String(err)}`)
+ return ipcError(ErrorCode.MeetingAlreadyRecording, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.STOP_RECORDING, async () => {
+ try {
+ await getMeetingModeService().stopRecording()
+ return ipcSuccess(undefined)
+ } catch (err) {
+ logger.error(`stopRecording 실패: ${err instanceof Error ? err.message : String(err)}`)
+ return ipcError(ErrorCode.MeetingNotRecording, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.ADD_MEMO, async (_event, params: MeetingAddMemoParams) => {
+ try {
+ const memo = await getMeetingModeService().addMemo(params.content)
+ return ipcSuccess(memo)
+ } catch (err) {
+ logger.error(`addMemo 실패: ${err instanceof Error ? err.message : String(err)}`)
+ return ipcError(ErrorCode.MeetingNotRecording, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.GET_STATE, () => {
+ try {
+ return ipcSuccess(getMeetingModeService().getStateInfo())
+ } catch (err) {
+ return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.GET_SESSIONS, (_event, params: MeetingGetSessionsParams) => {
+ try {
+ return ipcSuccess(getMeetingModeService().getSessions(params.page, params.pageSize))
+ } catch (err) {
+ return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.GET_SESSION, (_event, params: MeetingGetSessionParams) => {
+ try {
+ return ipcSuccess(getMeetingModeService().getSession(params.sessionId))
+ } catch (err) {
+ return ipcError(ErrorCode.MeetingSessionNotFound, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.DELETE_SESSION, (_event, params: MeetingDeleteSessionParams) => {
+ try {
+ getMeetingModeService().deleteSession(params.sessionId)
+ return ipcSuccess(undefined)
+ } catch (err) {
+ return ipcError(ErrorCode.MeetingSessionNotFound, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.UPDATE_TITLE, (_event, params: { sessionId: string; title: string }) => {
+ try {
+ getMeetingModeService().updateTitle(params.sessionId, params.title)
+ return ipcSuccess(undefined)
+ } catch (err) {
+ return ipcError(ErrorCode.MeetingSessionNotFound, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.EXPORT_PDF, async (_event, params: MeetingExportParams) => {
+ try {
+ const filePath = await getMeetingModeService().exportPdf(params.sessionId)
+ return ipcSuccess(filePath)
+ } catch (err) {
+ logger.error(`exportPdf 실패: ${err instanceof Error ? err.message : String(err)}`)
+ return ipcError(ErrorCode.MeetingPdfFailed, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ ipcMain.handle(ch.EXPORT_MARKDOWN, async (_event, params: MeetingExportParams) => {
+ try {
+ const filePath = await getMeetingModeService().exportMarkdown(params.sessionId)
+ return ipcSuccess(filePath)
+ } catch (err) {
+ logger.error(`exportMarkdown 실패: ${err instanceof Error ? err.message : String(err)}`)
+ return ipcError(ErrorCode.MeetingExportFailed, err instanceof Error ? err.message : String(err))
+ }
+ })
+
+ logger.info('Meeting Mode IPC handlers registered')
+}
diff --git a/src/main/services/MeetingModeService.ts b/src/main/services/MeetingModeService.ts
new file mode 100644
index 0000000..93583ca
--- /dev/null
+++ b/src/main/services/MeetingModeService.ts
@@ -0,0 +1,750 @@
+// src/main/services/MeetingModeService.ts
+// Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록
+// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
+
+import { EventEmitter } from 'events'
+import { BrowserWindow, Notification, dialog } from 'electron'
+import fs from 'fs'
+import { nanoid } from 'nanoid'
+import { eq, desc, sql } from 'drizzle-orm'
+import { getLogger } from './LoggerService'
+import { configGet } from './ConfigService'
+import { getMainWindow } from '../windows/WindowManager'
+import { getDatabase } from '../db'
+import { meetingSessions, meetingMemos } from '../db/schema'
+import { IPC_CHANNELS } from '@shared/ipc-channels'
+import { D3ROError, ErrorCode } from '@shared/errors'
+import type {
+ MeetingModeState,
+ MeetingMemo,
+ MeetingSessionSummary,
+ MeetingSessionDetail,
+ MeetingMinutes,
+ MeetingSessionPage,
+ MeetingStartResult,
+ MeetingModeStateInfo,
+ MeetingProcessingStep,
+ MeetingProcessingProgress,
+ CaptionSegment,
+} from '@shared/types'
+
+const logger = getLogger('MeetingModeService')
+
+const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다.
+전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다.
+
+규칙:
+- 전사록/메모에 없는 내용을 추가하지 마세요
+- 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요
+- 시간 순서를 유지하세요
+- 한국어로 작성하세요 (원문이 영어면 원어 유지)
+
+출력 형식:
+## 요약
+(3-5줄 핵심 요약)
+
+## 핵심 결정사항
+- (결정 1)
+- (결정 2)
+
+## 할 일 목록
+- [ ] (담당자가 있으면 포함) (할 일)
+
+## 타임라인
+| 시간 | 내용 |
+|------|------|
+| MM:SS | 주요 발언/이벤트 |`
+
+/** 긴 회의 청크 분할 기준 (자) */
+const LONG_MEETING_THRESHOLD = 20000
+const CHUNK_SIZE = 5000
+
+interface MeetingModeServiceEvents {
+ 'state-changed': (state: MeetingModeState) => void
+ 'segment': (segment: CaptionSegment) => void
+ 'memo-added': (memo: MeetingMemo) => void
+ 'processing-progress': (progress: MeetingProcessingProgress) => void
+ 'session-completed': (detail: MeetingSessionDetail) => void
+ 'error': (error: D3ROError) => void
+}
+
+class MeetingModeService extends EventEmitter {
+ private _state: MeetingModeState = 'idle'
+ private _sessionId: string | null = null
+ private _sessionStartedAt: number | null = null
+ private _segments: CaptionSegment[] = []
+ private _memos: MeetingMemo[] = []
+ private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
+ /** CaptionService session-saved 방지 플래그 */
+ private _meetingModeActive = false
+
+ // ── 공개 접근자 ──
+
+ getState(): MeetingModeState {
+ return this._state
+ }
+
+ getStateInfo(): MeetingModeStateInfo {
+ return {
+ state: this._state,
+ sessionId: this._sessionId,
+ elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0,
+ memoCount: this._memos.length,
+ segmentCount: this._segments.length,
+ }
+ }
+
+ isMeetingModeActive(): boolean {
+ return this._meetingModeActive
+ }
+
+ // ── 녹음 시작 ──
+
+ async startRecording(): Promise {
+ if (this._state !== 'idle') {
+ throw new D3ROError(
+ ErrorCode.MeetingAlreadyRecording,
+ `회의 모드가 이미 활성 상태입니다: ${this._state}`,
+ )
+ }
+
+ // CaptionService 상태 확인
+ const { getCaptionService } = await import('./CaptionService')
+ const captionService = getCaptionService()
+ const captionState = captionService.getState()
+ if (captionState !== 'inactive') {
+ throw new D3ROError(
+ ErrorCode.MeetingAlreadyRecording,
+ '자막 모드가 활성 상태입니다. 먼저 자막을 종료해주세요.',
+ )
+ }
+
+ this._setState('recording')
+ this._meetingModeActive = true
+
+ const sessionId = nanoid()
+ const now = Date.now()
+
+ this._sessionId = sessionId
+ this._sessionStartedAt = now
+ this._segments = []
+ this._memos = []
+
+ // DB에 세션 생성
+ const db = getDatabase()
+ db.insert(meetingSessions).values({
+ id: sessionId,
+ status: 'recording',
+ startedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ }).run()
+
+ // CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시)
+ this._segmentHandler = (segment: CaptionSegment) => {
+ this._segments.push(segment)
+ this.emit('segment', segment)
+ this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
+ }
+ captionService.on('segment', this._segmentHandler)
+
+ try {
+ await captionService.start()
+ } catch (err) {
+ // 시작 실패 시 복원
+ captionService.off('segment', this._segmentHandler)
+ this._segmentHandler = null
+ this._meetingModeActive = false
+ this._sessionId = null
+ this._sessionStartedAt = null
+
+ db.update(meetingSessions).set({
+ status: 'error',
+ errorMessage: err instanceof Error ? err.message : String(err),
+ updatedAt: Date.now(),
+ }).where(eq(meetingSessions.id, sessionId)).run()
+
+ this._setState('idle')
+ throw err
+ }
+
+ logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
+ this._sendStateToRenderer()
+
+ return { sessionId }
+ }
+
+ // ── 메모 추가 ──
+
+ async addMemo(content: string): Promise {
+ if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) {
+ throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
+ }
+
+ const memo: MeetingMemo = {
+ id: nanoid(),
+ sessionId: this._sessionId,
+ content,
+ timestampMs: Date.now() - this._sessionStartedAt,
+ createdAt: Date.now(),
+ }
+
+ this._memos.push(memo)
+
+ // DB 저장
+ const db = getDatabase()
+ db.insert(meetingMemos).values({
+ id: memo.id,
+ sessionId: memo.sessionId,
+ content: memo.content,
+ timestampMs: memo.timestampMs,
+ createdAt: memo.createdAt,
+ }).run()
+
+ this.emit('memo-added', memo)
+ logger.debug(`메모 추가: [${this._formatTime(memo.timestampMs)}] ${content}`)
+
+ return memo
+ }
+
+ // ── 녹음 종료 ──
+
+ async stopRecording(): Promise {
+ if (this._state !== 'recording') {
+ throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
+ }
+
+ const sessionId = this._sessionId!
+ const now = Date.now()
+
+ // CaptionService 종료
+ const { getCaptionService } = await import('./CaptionService')
+ const captionService = getCaptionService()
+
+ if (this._segmentHandler) {
+ captionService.off('segment', this._segmentHandler)
+ this._segmentHandler = null
+ }
+
+ await captionService.stop()
+
+ // 세션 종료 시간 기록
+ const db = getDatabase()
+ db.update(meetingSessions).set({
+ endedAt: now,
+ durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
+ updatedAt: now,
+ }).where(eq(meetingSessions.id, sessionId)).run()
+
+ logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
+
+ // 후처리 시작 (비동기)
+ this._runPostProcessing().catch((err: unknown) => {
+ logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`)
+ })
+ }
+
+ // ── 후처리 파이프라인 ──
+
+ private async _runPostProcessing(): Promise {
+ this._setState('processing')
+ this._sendStateToRenderer()
+ const sessionId = this._sessionId!
+ const db = getDatabase()
+
+ try {
+ db.update(meetingSessions).set({
+ status: 'processing',
+ updatedAt: Date.now(),
+ }).where(eq(meetingSessions.id, sessionId)).run()
+
+ // Step 1: 전사 텍스트 합산
+ this._sendProgress(sessionId, 'merging', 20)
+ const rawTranscript = this._segments
+ .map((s) => `[${this._formatTime(s.timestamp)}] ${s.text}`)
+ .join('\n')
+
+ // Step 2: 메모 포맷팅
+ const formattedMemos = this._memos
+ .map((m) => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`)
+ .join('\n')
+
+ // Step 3: LLM 구조화
+ this._sendProgress(sessionId, 'generating', 40)
+ const { getLocalLLMService } = await import('./LocalLLMService')
+ const llmService = getLocalLLMService()
+ const prompt = this._buildPrompt(rawTranscript, formattedMemos)
+ const sttEnd = Date.now()
+
+ let resultText: string
+ let resultModel: string
+
+ if (rawTranscript.length > LONG_MEETING_THRESHOLD) {
+ // 긴 회의: 2-pass 요약
+ const chunks = this._splitIntoChunks(rawTranscript, CHUNK_SIZE)
+ const chunkResults = []
+ for (let i = 0; i < chunks.length; i++) {
+ const pct = 40 + Math.floor((i / chunks.length) * 30)
+ this._sendProgress(sessionId, 'generating', pct)
+ const chunkResult = await llmService.generate(
+ `다음 회의 전사 일부를 3줄로 요약하세요:\n\n${chunks[i]}`,
+ { temperature: 0.3 },
+ )
+ chunkResults.push(chunkResult.text)
+ }
+ const condensed = chunkResults.join('\n\n')
+ const finalResult = await llmService.generate(
+ this._buildPrompt(condensed, formattedMemos),
+ {
+ systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
+ temperature: 0.3,
+ maxTokens: 4096,
+ },
+ )
+ resultText = finalResult.text
+ resultModel = finalResult.model
+ } else {
+ const result = await llmService.generate(prompt, {
+ systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
+ temperature: 0.3,
+ maxTokens: 4096,
+ })
+ resultText = result.text
+ resultModel = result.model
+ }
+ const llmEnd = Date.now()
+
+ // Step 4: 마크다운 파싱
+ this._sendProgress(sessionId, 'parsing', 85)
+ const minutes = this._parseMinutes(resultText)
+
+ // Step 5: DB 저장
+ this._sendProgress(sessionId, 'saving', 90)
+ const now = Date.now()
+ db.update(meetingSessions).set({
+ status: 'completed',
+ rawTranscript,
+ minutesMarkdown: resultText,
+ minutesJson: JSON.stringify(minutes),
+ sttModel: configGet('sttModelId') as string | undefined,
+ llmModel: resultModel,
+ sttLatencyMs: sttEnd - (this._sessionStartedAt ?? sttEnd),
+ llmLatencyMs: llmEnd - sttEnd,
+ updatedAt: now,
+ }).where(eq(meetingSessions.id, sessionId)).run()
+
+ // 자동 제목 생성
+ const autoTitle = minutes.summary.split('\n')[0].slice(0, 50) || '무제 회의'
+ db.update(meetingSessions).set({
+ title: autoTitle,
+ updatedAt: now,
+ }).where(eq(meetingSessions.id, sessionId)).run()
+
+ // Step 6: Windows 알림
+ this._sendProgress(sessionId, 'notifying', 95)
+ new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show()
+
+ // 완료 이벤트
+ this._sendProgress(sessionId, 'notifying', 100)
+ const detail = this._loadSessionDetail(sessionId)
+ if (detail) {
+ this.emit('session-completed', detail)
+ this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail)
+ }
+
+ this._resetSession()
+ this._setState('idle')
+ this._sendStateToRenderer()
+ logger.info(`회의록 생성 완료: sessionId=${sessionId}`)
+ } catch (err) {
+ db.update(meetingSessions).set({
+ status: 'error',
+ errorMessage: err instanceof Error ? err.message : String(err),
+ updatedAt: Date.now(),
+ }).where(eq(meetingSessions.id, sessionId)).run()
+
+ this._resetSession()
+ this._setState('idle')
+ this._sendStateToRenderer()
+
+ const d3roErr = new D3ROError(
+ ErrorCode.MeetingProcessingFailed,
+ `회의록 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
+ )
+ this.emit('error', d3roErr)
+ this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
+ code: d3roErr.code,
+ message: d3roErr.message,
+ })
+ logger.error(`후처리 실패: ${d3roErr.message}`)
+ }
+ }
+
+ // ── 세션 조회 ──
+
+ getSessions(page: number, pageSize: number): MeetingSessionPage {
+ const db = getDatabase()
+ const offset = (page - 1) * pageSize
+
+ const countResult = db.select({ count: sql`count(*)` })
+ .from(meetingSessions)
+ .get()
+ const total = countResult?.count ?? 0
+
+ const rows = db.select()
+ .from(meetingSessions)
+ .orderBy(desc(meetingSessions.createdAt))
+ .limit(pageSize)
+ .offset(offset)
+ .all()
+
+ const sessions: MeetingSessionSummary[] = rows.map((row) => {
+ const memoCount = db.select({ count: sql`count(*)` })
+ .from(meetingMemos)
+ .where(eq(meetingMemos.sessionId, row.id))
+ .get()?.count ?? 0
+
+ return {
+ id: row.id,
+ title: row.title,
+ status: row.status as MeetingSessionSummary['status'],
+ startedAt: row.startedAt,
+ endedAt: row.endedAt,
+ durationMs: row.durationMs,
+ memoCount,
+ }
+ })
+
+ return {
+ sessions,
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ }
+ }
+
+ getSession(sessionId: string): MeetingSessionDetail {
+ const detail = this._loadSessionDetail(sessionId)
+ if (!detail) {
+ throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
+ }
+ return detail
+ }
+
+ deleteSession(sessionId: string): void {
+ const db = getDatabase()
+ const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
+ if (!row) {
+ throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
+ }
+
+ db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run()
+ db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
+ logger.info(`회의 세션 삭제: ${sessionId}`)
+ }
+
+ updateTitle(sessionId: string, title: string): void {
+ const db = getDatabase()
+ const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
+ if (!row) {
+ throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
+ }
+ db.update(meetingSessions).set({ title, updatedAt: Date.now() })
+ .where(eq(meetingSessions.id, sessionId)).run()
+ }
+
+ // ── 내보내기 ──
+
+ async exportPdf(sessionId: string): Promise {
+ const session = this._loadSessionDetail(sessionId)
+ if (!session) {
+ throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
+ }
+
+ const html = this._buildPdfHtml(session)
+
+ const { filePath } = await dialog.showSaveDialog({
+ title: 'PDF로 내보내기',
+ defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.pdf`,
+ filters: [{ name: 'PDF', extensions: ['pdf'] }],
+ })
+ if (!filePath) return ''
+
+ const win = new BrowserWindow({ show: false, width: 800, height: 600 })
+ try {
+ await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
+ const pdfBuffer = await win.webContents.printToPDF({
+ printBackground: true,
+ pageSize: 'A4',
+ margins: { top: 20, bottom: 20, left: 20, right: 20 },
+ })
+ fs.writeFileSync(filePath, pdfBuffer)
+ logger.info(`PDF 내보내기 완료: ${filePath}`)
+ return filePath
+ } finally {
+ win.close()
+ }
+ }
+
+ async exportMarkdown(sessionId: string): Promise {
+ const session = this._loadSessionDetail(sessionId)
+ if (!session) {
+ throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
+ }
+
+ const { filePath } = await dialog.showSaveDialog({
+ title: '마크다운으로 내보내기',
+ defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.md`,
+ filters: [{ name: 'Markdown', extensions: ['md'] }],
+ })
+ if (!filePath) return ''
+
+ const md = this._buildExportMarkdown(session)
+ fs.writeFileSync(filePath, md, 'utf-8')
+ logger.info(`마크다운 내보내기 완료: ${filePath}`)
+ return filePath
+ }
+
+ // ── 내부 헬퍼 ──
+
+ private _setState(state: MeetingModeState): void {
+ this._state = state
+ this.emit('state-changed', state)
+ }
+
+ private _resetSession(): void {
+ this._sessionId = null
+ this._sessionStartedAt = null
+ this._segments = []
+ this._memos = []
+ this._meetingModeActive = false
+ }
+
+ private _sendToRenderer(channel: string, data: unknown): void {
+ const win = getMainWindow()
+ if (win && !win.isDestroyed()) {
+ win.webContents.send(channel, data)
+ }
+ }
+
+ private _sendStateToRenderer(): void {
+ this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, this.getStateInfo())
+ }
+
+ private _sendProgress(sessionId: string, step: MeetingProcessingStep, percent: number): void {
+ const progress: MeetingProcessingProgress = { sessionId, step, percent }
+ this.emit('processing-progress', progress)
+ this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress)
+ }
+
+ private _formatTime(ms: number): string {
+ const totalSeconds = Math.floor(ms / 1000)
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
+ }
+
+ private _formatDateFile(epochMs: number): string {
+ const d = new Date(epochMs)
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
+ }
+
+ private _formatDateRange(startedAt: number, endedAt: number | null): string {
+ const fmt = (ts: number): string => {
+ const d = new Date(ts)
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+ }
+ if (!endedAt) return fmt(startedAt)
+ return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
+ }
+
+ private _buildPrompt(rawTranscript: string, formattedMemos: string): string {
+ return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.`
+ }
+
+ private _parseMinutes(markdown: string): MeetingMinutes {
+ const minutes: MeetingMinutes = {
+ summary: '',
+ decisions: [],
+ actionItems: [],
+ timeline: [],
+ }
+
+ const sections = markdown.split(/^## /m)
+ for (const section of sections) {
+ const lines = section.trim().split('\n')
+ const heading = lines[0]?.toLowerCase() ?? ''
+ const body = lines.slice(1).join('\n').trim()
+
+ if (heading.includes('요약') || heading.includes('summary')) {
+ minutes.summary = body
+ } else if (heading.includes('결정') || heading.includes('decision')) {
+ minutes.decisions = body
+ .split('\n')
+ .filter((l) => l.startsWith('-') || l.startsWith('*'))
+ .map((l) => l.replace(/^[-*]\s*/, '').trim())
+ } else if (heading.includes('할 일') || heading.includes('action')) {
+ minutes.actionItems = body
+ .split('\n')
+ .filter((l) => l.startsWith('-') || l.startsWith('*'))
+ .map((l) => {
+ const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
+ return { task: text }
+ })
+ } else if (heading.includes('타임라인') || heading.includes('timeline')) {
+ const tableRows = body
+ .split('\n')
+ .filter((l) => l.includes('|') && !l.includes('---'))
+ for (const row of tableRows) {
+ const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
+ if (cells.length >= 2) {
+ minutes.timeline.push({
+ time: cells[0],
+ content: cells[1],
+ type: cells[1].includes('📝') ? 'memo' : 'transcript',
+ })
+ }
+ }
+ }
+ }
+
+ return minutes
+ }
+
+ private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null {
+ const db = getDatabase()
+ const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
+ if (!row) return null
+
+ const memoRows = db.select().from(meetingMemos)
+ .where(eq(meetingMemos.sessionId, sessionId))
+ .orderBy(meetingMemos.timestampMs)
+ .all()
+
+ const memos: MeetingMemo[] = memoRows.map((m) => ({
+ id: m.id,
+ sessionId: m.sessionId,
+ content: m.content,
+ timestampMs: m.timestampMs,
+ createdAt: m.createdAt,
+ }))
+
+ let minutes: MeetingMinutes | null = null
+ if (row.minutesJson) {
+ try {
+ minutes = JSON.parse(row.minutesJson) as MeetingMinutes
+ } catch {
+ logger.warn(`회의록 JSON 파싱 실패: sessionId=${sessionId}`)
+ }
+ }
+
+ return {
+ id: row.id,
+ title: row.title,
+ status: row.status as MeetingSessionDetail['status'],
+ startedAt: row.startedAt,
+ endedAt: row.endedAt,
+ durationMs: row.durationMs,
+ memoCount: memos.length,
+ rawTranscript: row.rawTranscript,
+ minutesMarkdown: row.minutesMarkdown,
+ minutes,
+ memos,
+ sttModel: row.sttModel,
+ llmModel: row.llmModel,
+ errorMessage: row.errorMessage,
+ }
+ }
+
+ private _splitIntoChunks(text: string, chunkSize: number): string[] {
+ const chunks: string[] = []
+ for (let i = 0; i < text.length; i += chunkSize) {
+ chunks.push(text.slice(i, i + chunkSize))
+ }
+ return chunks
+ }
+
+ private _buildPdfHtml(session: MeetingSessionDetail): string {
+ const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
+ const minutesHtml = session.minutesMarkdown
+ ? this._markdownToSimpleHtml(session.minutesMarkdown)
+ : '회의록이 생성되지 않았습니다.
'
+
+ return `
+
+
+
+
+
+
+ ${session.title ?? '무제 회의'}
+ 일시: ${this._formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분
+ ${minutesHtml}
+
+`
+ }
+
+ private _buildExportMarkdown(session: MeetingSessionDetail): string {
+ const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
+ const memoTable = session.memos.length > 0
+ ? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${this._formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
+ : ''
+
+ return `# 회의록 — ${session.title ?? '무제 회의'}
+
+- **일시**: ${this._formatDateRange(session.startedAt, session.endedAt)}
+- **소요 시간**: ${durationMin}분
+- **STT 모델**: ${session.sttModel ?? '-'}
+- **LLM 모델**: ${session.llmModel ?? '-'}
+
+---
+
+${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
+
+---
+
+${memoTable}
+
+---
+
+## 원문 전사
+
+${session.rawTranscript ?? '(없음)'}
+`
+ }
+
+ /** 간단한 마크다운→HTML 변환 (PDF 생성용) */
+ private _markdownToSimpleHtml(md: string): string {
+ return md
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/^\- \[.\] (.+)$/gm, '$1')
+ .replace(/^\- (.+)$/gm, '$1')
+ .replace(/^\* (.+)$/gm, '$1')
+ .replace(/\n{2,}/g, '
')
+ .replace(/^(?!<[h|l|t|p])/gm, '')
+ }
+}
+
+// ── 싱글톤 ──
+let instance: MeetingModeService | null = null
+
+export function getMeetingModeService(): MeetingModeService {
+ if (!instance) {
+ instance = new MeetingModeService()
+ }
+ return instance
+}
diff --git a/src/main/services/VoiceModeService.ts b/src/main/services/VoiceModeService.ts
index cdbebbf..9f63eb3 100644
--- a/src/main/services/VoiceModeService.ts
+++ b/src/main/services/VoiceModeService.ts
@@ -821,6 +821,12 @@ class VoiceModeService extends EventEmitter {
logger.warn('Cannot start caption: dictation session active')
return
}
+ // 회의 모드 녹음 중이면 자막 시작 불가 (상호 배제)
+ const { getMeetingModeService } = await import('./MeetingModeService')
+ if (getMeetingModeService().getState() === 'recording') {
+ logger.warn('Cannot start caption: meeting mode recording active')
+ return
+ }
await caption.start()
logger.info('Caption started via hotkey')
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index e0fdf25..e476313 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -136,6 +136,18 @@ import type {
TemplateSessionInfo,
TemplateFieldCompletedEvent,
TemplateSessionCompletedEvent,
+ MeetingStartResult,
+ MeetingMemo,
+ MeetingAddMemoParams,
+ MeetingModeStateInfo,
+ MeetingSessionPage,
+ MeetingGetSessionsParams,
+ MeetingSessionDetail,
+ MeetingGetSessionParams,
+ MeetingDeleteSessionParams,
+ MeetingExportParams,
+ MeetingProcessingProgress,
+ CaptionSegment,
} from '@shared/types'
import { Feature } from '@shared/types'
import type { IPCResult } from '@shared/errors'
@@ -553,6 +565,40 @@ const electronAPI = {
onError: (cb: (data: ConversationError) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE_CONVERSATION.ERROR, cb),
},
+
+ // ── Meeting Mode (Phase 14) ───────────────────────────
+ meetingMode: {
+ startRecording: () =>
+ invoke(IPC_CHANNELS.MEETING_MODE.START_RECORDING),
+ stopRecording: () =>
+ invoke(IPC_CHANNELS.MEETING_MODE.STOP_RECORDING),
+ addMemo: (params: MeetingAddMemoParams) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params),
+ getState: () =>
+ invoke(IPC_CHANNELS.MEETING_MODE.GET_STATE),
+ getSessions: (params: MeetingGetSessionsParams) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params),
+ getSession: (params: MeetingGetSessionParams) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.GET_SESSION, params),
+ deleteSession: (params: MeetingDeleteSessionParams) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.DELETE_SESSION, params),
+ updateTitle: (params: { sessionId: string; title: string }) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.UPDATE_TITLE, params),
+ exportPdf: (params: MeetingExportParams) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_PDF, params),
+ exportMarkdown: (params: MeetingExportParams) =>
+ invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_MARKDOWN, params),
+ onStateChanged: (cb: (e: MeetingModeStateInfo) => void): Unsubscribe =>
+ on(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, cb),
+ onSegment: (cb: (e: CaptionSegment) => void): Unsubscribe =>
+ on(IPC_CHANNELS.MEETING_MODE.SEGMENT, cb),
+ onProcessingProgress: (cb: (e: MeetingProcessingProgress) => void): Unsubscribe =>
+ on(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, cb),
+ onSessionCompleted: (cb: (e: MeetingSessionDetail) => void): Unsubscribe =>
+ on(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, cb),
+ onError: (cb: (e: { code: number; message: string }) => void): Unsubscribe =>
+ on(IPC_CHANNELS.MEETING_MODE.ERROR, cb),
+ },
} as const
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx
index bc031ea..b961e78 100644
--- a/src/renderer/components/AppLayout.tsx
+++ b/src/renderer/components/AppLayout.tsx
@@ -9,6 +9,7 @@ import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
+import GroupsIcon from '@mui/icons-material/Groups'
import SettingsIcon from '@mui/icons-material/Settings'
import { Led } from './ds'
import { DashboardPage } from '../pages/DashboardPage'
@@ -17,6 +18,7 @@ import { DictionaryPage } from '../pages/DictionaryPage'
import { CommandsPage } from '../pages/CommandsPage'
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
+import { MeetingModePage } from '../pages/MeetingModePage'
import { SettingsModal } from './SettingsModal'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
@@ -26,7 +28,7 @@ import { useI18n } from '../i18n'
import type { TranslationKey } from '../i18n'
import type { LicenseTier } from '@shared/types'
-type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge'
+type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting'
interface NavItem {
route: Route
@@ -42,6 +44,7 @@ const NAV_ITEMS: NavItem[] = [
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: },
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: },
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: },
+ { route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: },
]
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
@@ -225,6 +228,7 @@ export function AppLayout(): React.ReactElement {
{currentRoute === 'commands' && }
{currentRoute === 'conversation' && }
{currentRoute === 'knowledge' && }
+ {currentRoute === 'meeting' && }
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index ac3876b..d298bf4 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -455,10 +455,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
-
-
-
-
+
+
+
+
@@ -627,10 +627,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
-
-
-
-
+
+
+
+
diff --git a/src/renderer/components/TemplateSection.tsx b/src/renderer/components/TemplateSection.tsx
index 41c9db1..92caa3c 100644
--- a/src/renderer/components/TemplateSection.tsx
+++ b/src/renderer/components/TemplateSection.tsx
@@ -277,7 +277,7 @@ export function TemplateSection(): React.ReactElement {
fullWidth
multiline
rows={3}
- helperText="Use {{fieldName}} for placeholders"
+ helperText={t('template.outputHelperText')}
/>
diff --git a/src/renderer/components/ds/MetalDial.tsx b/src/renderer/components/ds/MetalDial.tsx
index 26a25e1..0bd05aa 100644
--- a/src/renderer/components/ds/MetalDial.tsx
+++ b/src/renderer/components/ds/MetalDial.tsx
@@ -56,10 +56,10 @@ export function MetalDial({
background: `
repeating-radial-gradient(
circle at 50% 50%,
- #e6e7e9 0px,
- #e6e7e9 1px,
- #b0b2b5 1.5px,
- #b0b2b5 2.5px
+ ${d3roPalette.text.disabled} 0px,
+ ${d3roPalette.text.disabled} 1px,
+ ${d3roPalette.text.label} 1.5px,
+ ${d3roPalette.text.label} 2.5px
)
`,
transform: `rotate(${rotation}deg)`,
diff --git a/src/renderer/i18n/de.json b/src/renderer/i18n/de.json
index 33be0b9..76e338e 100644
--- a/src/renderer/i18n/de.json
+++ b/src/renderer/i18n/de.json
@@ -1,13 +1,11 @@
{
"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",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} Einträge",
"dashboard.stat": "Statistiken",
"dashboard.sys": "System",
-
"history.title": "Transkriptionsverlauf",
"history.search": "Suchen...",
"history.entries": "{{count}} Einträge",
@@ -34,7 +31,6 @@
"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",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "Wort hinzufügen",
"dictionary.word": "Wort",
"dictionary.pronunciation": "Aussprache (optional)",
-
"commands.title": "LLM-Befehle",
"commands.count": "{{count}} Befehle",
"commands.add": "Hinzufügen",
@@ -62,14 +57,12 @@
"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.",
@@ -82,20 +75,17 @@
"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)",
@@ -103,7 +93,6 @@
"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)",
@@ -112,7 +101,6 @@
"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).",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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",
@@ -160,7 +145,6 @@
"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",
@@ -171,7 +155,6 @@
"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.",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "AKTIV",
"service.standby": "BEREITSCHAFT",
-
"common.cancel": "Abbrechen",
"common.save": "Speichern",
"common.delete": "Löschen",
@@ -202,7 +183,51 @@
"common.copy": "Kopieren",
"common.test": "Testen",
"common.stop": "Stoppen",
-
"date.today": "Heute",
- "date.yesterday": "Gestern"
+ "date.yesterday": "Gestern",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "Koreanisch",
+ "settings.sttLang.en": "Englisch",
+ "settings.sttLang.ja": "Japanisch",
+ "settings.sttLang.zh": "Chinesisch",
+ "template.outputHelperText": "Verwenden Sie {{Feldname}} für Platzhalter",
+ "nav.meeting": "Meeting",
+ "meeting.title": "Meeting-Modus",
+ "meeting.newMeeting": "Neues Meeting",
+ "meeting.startRecording": "Aufnahme starten",
+ "meeting.stopRecording": "Aufnahme beenden",
+ "meeting.recording": "Aufnahme läuft",
+ "meeting.processing": "Protokoll wird erstellt...",
+ "meeting.completed": "Abgeschlossen",
+ "meeting.error": "Fehler",
+ "meeting.elapsed": "Vergangen",
+ "meeting.memoPlaceholder": "Memo eingeben (Enter zum Hinzufügen)",
+ "meeting.memoAdded": "Memo hinzugefügt",
+ "meeting.noSessions": "Keine Meeting-Aufnahmen",
+ "meeting.noSessionsDesc": "Drücke «Neues Meeting», um die Aufnahme zu starten",
+ "meeting.summary": "Zusammenfassung",
+ "meeting.decisions": "Beschlüsse",
+ "meeting.actionItems": "Aufgaben",
+ "meeting.timeline": "Zeitlinie",
+ "meeting.transcript": "Rohtranskript",
+ "meeting.memos": "Memos",
+ "meeting.exportPdf": "Als PDF exportieren",
+ "meeting.exportMarkdown": "Als Markdown exportieren",
+ "meeting.delete": "Löschen",
+ "meeting.deleteConfirm": "Diesen Meeting-Eintrag löschen?",
+ "meeting.backToList": "Zurück zur Liste",
+ "meeting.notificationTitle": "Protokoll fertig",
+ "meeting.notificationBody": "Das Meeting-Protokoll wurde erstellt.",
+ "meeting.sessions": "{{count}} Sitzungen",
+ "meeting.duration": "{{minutes}} Min.",
+ "meeting.editTitle": "Titel bearbeiten",
+ "meeting.untitled": "Meeting ohne Titel",
+ "meeting.processingStep.merging": "Transkript zusammenführen...",
+ "meeting.processingStep.generating": "Protokoll mit LLM erstellen...",
+ "meeting.processingStep.parsing": "Protokoll analysieren...",
+ "meeting.processingStep.saving": "Speichern...",
+ "meeting.processingStep.notifying": "Benachrichtigung senden..."
}
diff --git a/src/renderer/i18n/en.json b/src/renderer/i18n/en.json
index 2763e87..4e4c894 100644
--- a/src/renderer/i18n/en.json
+++ b/src/renderer/i18n/en.json
@@ -1,13 +1,11 @@
{
"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.sessionOverview": "Session Overview",
"dashboard.systemStatus": "System Status",
"dashboard.sessionsToday": "Today's Sessions",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} entries",
"dashboard.stat": "Stats",
"dashboard.sys": "System",
-
"history.title": "Transcription History",
"history.search": "Search...",
"history.entries": "{{count}} entries",
@@ -34,7 +31,6 @@
"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",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "Add Word",
"dictionary.word": "Word",
"dictionary.pronunciation": "Pronunciation (optional)",
-
"commands.title": "LLM Commands",
"commands.count": "{{count}} commands",
"commands.add": "Add",
@@ -62,14 +57,12 @@
"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.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.",
@@ -89,20 +82,17 @@
"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.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.microphone": "Microphone",
"settings.inputDevice": "Input Device",
"settings.deviceDefault": "(Default)",
@@ -110,7 +100,6 @@
"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)",
@@ -119,7 +108,6 @@
"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).",
@@ -132,7 +120,6 @@
"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",
@@ -140,13 +127,11 @@
"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",
@@ -167,7 +152,6 @@
"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",
@@ -179,7 +163,6 @@
"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.",
@@ -188,7 +171,6 @@
"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",
@@ -198,7 +180,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "Cancel",
"common.save": "Save",
"common.delete": "Delete",
@@ -210,7 +191,6 @@
"common.copy": "Copy",
"common.test": "Test",
"common.stop": "Stop",
-
"memo.tags": "Tags",
"memo.addTag": "Add Tag",
"memo.removeTag": "Remove Tag",
@@ -221,7 +201,6 @@
"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.",
@@ -232,7 +211,6 @@
"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",
@@ -251,24 +229,19 @@
"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",
@@ -343,7 +316,6 @@
"license.unlimited": "Unlimited",
"license.locked": "Locked",
"license.nav": "License",
-
"fileTranscription.title": "File Transcription",
"fileTranscription.dropZone": "Drop audio/video file here",
"fileTranscription.dropZoneHint": "Supports MP3, WAV, M4A, MP4, MKV, WEBM",
@@ -356,7 +328,6 @@
"fileTranscription.retry": "Retry",
"fileTranscription.error.invalidFormat": "Unsupported file format",
"fileTranscription.error.unknown": "Unknown error",
-
"meetingSummary.title": "Meeting Summary",
"meetingSummary.generating": "Generating summary...",
"meetingSummary.summary": "Summary",
@@ -365,7 +336,6 @@
"meetingSummary.exportMarkdown": "Export as Markdown",
"meetingSummary.noSummary": "No summary available",
"meetingSummary.generate": "Generate Summary",
-
"template.title": "Dictation Templates",
"template.create": "New Template",
"template.edit": "Edit Template",
@@ -381,7 +351,6 @@
"template.startSession": "Start",
"template.cancelSession": "Cancel Session",
"template.empty": "No templates",
-
"nav.conversation": "Talk",
"conversation.title": "Voice Conversation",
"conversation.idle": "Idle",
@@ -393,7 +362,6 @@
"conversation.inputPlaceholder": "Type a message...",
"conversation.end": "End",
"conversation.clearHistory": "Clear history",
-
"nav.knowledge": "Knowledge",
"rag.title": "Knowledge Base",
"rag.addDocument": "Add Document",
@@ -410,12 +378,56 @@
"rag.sources": "Sources",
"rag.adding": "Adding...",
"rag.parsing": "Parsing document...",
-
"voiceAction.title": "Voice Actions",
"voiceAction.execute": "Execute",
"voiceAction.presets": "Preset Commands",
"voiceAction.history": "Action History",
"voiceAction.noHistory": "No action history",
"voiceAction.blocked": "Blocked (unsafe)",
- "voiceAction.executed": "Executed"
+ "voiceAction.executed": "Executed",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "한국어 (Korean)",
+ "settings.sttLang.en": "English",
+ "settings.sttLang.ja": "日本語 (Japanese)",
+ "settings.sttLang.zh": "中文 (Chinese)",
+ "template.outputHelperText": "Use {{fieldName}} for placeholders",
+ "nav.meeting": "Meeting",
+ "meeting.title": "Meeting Mode",
+ "meeting.newMeeting": "New Meeting",
+ "meeting.startRecording": "Start Recording",
+ "meeting.stopRecording": "Stop Recording",
+ "meeting.recording": "Recording",
+ "meeting.processing": "Generating minutes...",
+ "meeting.completed": "Completed",
+ "meeting.error": "Error",
+ "meeting.elapsed": "Elapsed",
+ "meeting.memoPlaceholder": "Type a memo (Enter to add)",
+ "meeting.memoAdded": "Memo added",
+ "meeting.noSessions": "No meeting recordings",
+ "meeting.noSessionsDesc": "Press New Meeting to start recording",
+ "meeting.summary": "Summary",
+ "meeting.decisions": "Decisions",
+ "meeting.actionItems": "Action Items",
+ "meeting.timeline": "Timeline",
+ "meeting.transcript": "Raw Transcript",
+ "meeting.memos": "Memos",
+ "meeting.exportPdf": "Export PDF",
+ "meeting.exportMarkdown": "Export Markdown",
+ "meeting.delete": "Delete",
+ "meeting.deleteConfirm": "Delete this meeting record?",
+ "meeting.backToList": "Back to List",
+ "meeting.notificationTitle": "Minutes Ready",
+ "meeting.notificationBody": "Meeting minutes have been generated.",
+ "meeting.sessions": "{{count}} sessions",
+ "meeting.duration": "{{minutes}} min",
+ "meeting.editTitle": "Edit Title",
+ "meeting.untitled": "Untitled Meeting",
+ "meeting.processingStep.merging": "Merging transcript...",
+ "meeting.processingStep.generating": "Generating minutes with LLM...",
+ "meeting.processingStep.parsing": "Parsing minutes...",
+ "meeting.processingStep.saving": "Saving...",
+ "meeting.processingStep.notifying": "Sending notification..."
}
diff --git a/src/renderer/i18n/es.json b/src/renderer/i18n/es.json
index 9599f6e..15d1cca 100644
--- a/src/renderer/i18n/es.json
+++ b/src/renderer/i18n/es.json
@@ -1,13 +1,11 @@
{
"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",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} entradas",
"dashboard.stat": "estadísticas",
"dashboard.sys": "sistema",
-
"history.title": "Historial de transcripciones",
"history.search": "Buscar...",
"history.entries": "{{count}} entradas",
@@ -34,7 +31,6 @@
"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",
@@ -47,7 +43,6 @@
"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",
@@ -62,14 +57,12 @@
"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.",
@@ -82,20 +75,17 @@
"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)",
@@ -103,7 +93,6 @@
"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)",
@@ -112,7 +101,6 @@
"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).",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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",
@@ -160,7 +145,6 @@
"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",
@@ -171,7 +155,6 @@
"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.",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVO",
"service.standby": "EN ESPERA",
-
"common.cancel": "Cancelar",
"common.save": "Guardar",
"common.delete": "Eliminar",
@@ -202,7 +183,51 @@
"common.copy": "Copiar",
"common.test": "Probar",
"common.stop": "Detener",
-
"date.today": "Hoy",
- "date.yesterday": "Ayer"
+ "date.yesterday": "Ayer",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "Coreano",
+ "settings.sttLang.en": "Inglés",
+ "settings.sttLang.ja": "Japonés",
+ "settings.sttLang.zh": "Chino",
+ "template.outputHelperText": "Use {{nombreCampo}} para marcadores de posición",
+ "nav.meeting": "Reunión",
+ "meeting.title": "Modo Reunión",
+ "meeting.newMeeting": "Nueva Reunión",
+ "meeting.startRecording": "Iniciar Grabación",
+ "meeting.stopRecording": "Detener Grabación",
+ "meeting.recording": "Grabando",
+ "meeting.processing": "Generando actas...",
+ "meeting.completed": "Completado",
+ "meeting.error": "Error",
+ "meeting.elapsed": "Tiempo transcurrido",
+ "meeting.memoPlaceholder": "Escribe un memo (Enter para añadir)",
+ "meeting.memoAdded": "Memo añadido",
+ "meeting.noSessions": "Sin grabaciones de reuniones",
+ "meeting.noSessionsDesc": "Pulsa Nueva Reunión para empezar a grabar",
+ "meeting.summary": "Resumen",
+ "meeting.decisions": "Decisiones",
+ "meeting.actionItems": "Tareas",
+ "meeting.timeline": "Línea de tiempo",
+ "meeting.transcript": "Transcripción bruta",
+ "meeting.memos": "Memos",
+ "meeting.exportPdf": "Exportar PDF",
+ "meeting.exportMarkdown": "Exportar Markdown",
+ "meeting.delete": "Eliminar",
+ "meeting.deleteConfirm": "¿Eliminar este registro de reunión?",
+ "meeting.backToList": "Volver a la lista",
+ "meeting.notificationTitle": "Actas listas",
+ "meeting.notificationBody": "Las actas de la reunión han sido generadas.",
+ "meeting.sessions": "{{count}} sesiones",
+ "meeting.duration": "{{minutes}} min",
+ "meeting.editTitle": "Editar título",
+ "meeting.untitled": "Reunión sin título",
+ "meeting.processingStep.merging": "Fusionando transcripción...",
+ "meeting.processingStep.generating": "Generando actas con LLM...",
+ "meeting.processingStep.parsing": "Analizando actas...",
+ "meeting.processingStep.saving": "Guardando...",
+ "meeting.processingStep.notifying": "Enviando notificación..."
}
diff --git a/src/renderer/i18n/fr.json b/src/renderer/i18n/fr.json
index e8a54ef..f6ea433 100644
--- a/src/renderer/i18n/fr.json
+++ b/src/renderer/i18n/fr.json
@@ -1,13 +1,11 @@
{
"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",
@@ -26,7 +24,6 @@
"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",
@@ -34,7 +31,6 @@
"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",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "Ajouter un mot",
"dictionary.word": "Mot",
"dictionary.pronunciation": "Prononciation (facultatif)",
-
"commands.title": "Commandes LLM",
"commands.count": "{{count}} commandes",
"commands.add": "Ajouter",
@@ -62,14 +57,12 @@
"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.",
@@ -82,20 +75,17 @@
"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)",
@@ -103,7 +93,6 @@
"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é)",
@@ -112,7 +101,6 @@
"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).",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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",
@@ -160,7 +145,6 @@
"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",
@@ -171,7 +155,6 @@
"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.",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "HORS LIGNE",
"service.active": "ACTIF",
"service.standby": "EN VEILLE",
-
"common.cancel": "Annuler",
"common.save": "Enregistrer",
"common.delete": "Supprimer",
@@ -202,7 +183,51 @@
"common.copy": "Copier",
"common.test": "Tester",
"common.stop": "Arrêter",
-
"date.today": "Aujourd'hui",
- "date.yesterday": "Hier"
+ "date.yesterday": "Hier",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "Coréen",
+ "settings.sttLang.en": "Anglais",
+ "settings.sttLang.ja": "Japonais",
+ "settings.sttLang.zh": "Chinois",
+ "template.outputHelperText": "Utilisez {{nomChamp}} pour les espaces réservés",
+ "nav.meeting": "Réunion",
+ "meeting.title": "Mode Réunion",
+ "meeting.newMeeting": "Nouvelle Réunion",
+ "meeting.startRecording": "Démarrer l'enregistrement",
+ "meeting.stopRecording": "Arrêter l'enregistrement",
+ "meeting.recording": "Enregistrement en cours",
+ "meeting.processing": "Génération du compte-rendu...",
+ "meeting.completed": "Terminé",
+ "meeting.error": "Erreur",
+ "meeting.elapsed": "Temps écoulé",
+ "meeting.memoPlaceholder": "Saisir un mémo (Entrée pour ajouter)",
+ "meeting.memoAdded": "Mémo ajouté",
+ "meeting.noSessions": "Aucun enregistrement de réunion",
+ "meeting.noSessionsDesc": "Appuyez sur Nouvelle Réunion pour commencer l'enregistrement",
+ "meeting.summary": "Résumé",
+ "meeting.decisions": "Décisions",
+ "meeting.actionItems": "Actions à mener",
+ "meeting.timeline": "Chronologie",
+ "meeting.transcript": "Transcription brute",
+ "meeting.memos": "Mémos",
+ "meeting.exportPdf": "Exporter en PDF",
+ "meeting.exportMarkdown": "Exporter en Markdown",
+ "meeting.delete": "Supprimer",
+ "meeting.deleteConfirm": "Supprimer cet enregistrement de réunion ?",
+ "meeting.backToList": "Retour à la liste",
+ "meeting.notificationTitle": "Compte-rendu prêt",
+ "meeting.notificationBody": "Le compte-rendu de la réunion a été généré.",
+ "meeting.sessions": "{{count}} séances",
+ "meeting.duration": "{{minutes}} min",
+ "meeting.editTitle": "Modifier le titre",
+ "meeting.untitled": "Réunion sans titre",
+ "meeting.processingStep.merging": "Fusion de la transcription...",
+ "meeting.processingStep.generating": "Génération du compte-rendu avec LLM...",
+ "meeting.processingStep.parsing": "Analyse du compte-rendu...",
+ "meeting.processingStep.saving": "Enregistrement...",
+ "meeting.processingStep.notifying": "Envoi de la notification..."
}
diff --git a/src/renderer/i18n/ja.json b/src/renderer/i18n/ja.json
index 27a62fa..545ec80 100644
--- a/src/renderer/i18n/ja.json
+++ b/src/renderer/i18n/ja.json
@@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "ローカルAI音声アシスタント",
-
"nav.dashboard": "ダッシュボード",
"nav.history": "履歴",
"nav.dictionary": "辞書",
"nav.commands": "コマンド",
"nav.settings": "設定",
-
"dashboard.sessionOverview": "セッション概要",
"dashboard.systemStatus": "システム状態",
"dashboard.sessionsToday": "本日のセッション",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}件",
"dashboard.stat": "統計",
"dashboard.sys": "システム",
-
"history.title": "文字起こし履歴",
"history.search": "検索...",
"history.entries": "{{count}}件",
@@ -34,7 +31,6 @@
"history.noResults": "検索結果なし",
"history.noHistory": "履歴なし — 録音を開始してください",
"history.count": "{{count}}件",
-
"dictionary.title": "カスタム辞書",
"dictionary.words": "{{count}}語",
"dictionary.add": "追加",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "単語を追加",
"dictionary.word": "単語",
"dictionary.pronunciation": "読み仮名 (任意)",
-
"commands.title": "LLMコマンド",
"commands.count": "{{count}}個",
"commands.add": "追加",
@@ -62,14 +57,12 @@
"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": "押しながら話します。キーを離すと文字起こしが始まります。",
@@ -82,20 +75,17 @@
"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": "(デフォルト)",
@@ -103,7 +93,6 @@
"settings.insertMethod": "挿入方式",
"settings.insertClipboard": "クリップボード (Ctrl+V)",
"settings.insertKeyboard": "キーボード入力",
-
"settings.whisperModel": "Whisperモデル",
"settings.model.tiny": "tiny (39 MB, 最速)",
"settings.model.base": "base (74 MB, バランス)",
@@ -112,7 +101,6 @@
"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)。",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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": "始める",
@@ -160,7 +145,6 @@
"onboarding.done.start": "始める",
"onboarding.back": "戻る",
"onboarding.next": "次へ",
-
"hotkey.title": "ショートカット設定",
"hotkey.dictationTitle": "ディクテーションショートカットの設定",
"hotkey.oneTouchTitle": "ワンタッチモードショートカットの設定",
@@ -171,7 +155,6 @@
"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を実行するための無料ツールです。",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "キャンセル",
"common.save": "保存",
"common.delete": "削除",
@@ -202,7 +183,51 @@
"common.copy": "コピー",
"common.test": "テスト",
"common.stop": "停止",
-
"date.today": "今日",
- "date.yesterday": "昨日"
+ "date.yesterday": "昨日",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "한국語",
+ "settings.sttLang.en": "English",
+ "settings.sttLang.ja": "日本語",
+ "settings.sttLang.zh": "中文",
+ "template.outputHelperText": "{{フィールド名}} でプレースホルダーを使用",
+ "nav.meeting": "ミーティング",
+ "meeting.title": "ミーティングモード",
+ "meeting.newMeeting": "新規ミーティング",
+ "meeting.startRecording": "録音開始",
+ "meeting.stopRecording": "録音停止",
+ "meeting.recording": "録音中",
+ "meeting.processing": "議事録を生成中...",
+ "meeting.completed": "完了",
+ "meeting.error": "エラー",
+ "meeting.elapsed": "経過時間",
+ "meeting.memoPlaceholder": "メモを入力(Enterで追加)",
+ "meeting.memoAdded": "メモを追加しました",
+ "meeting.noSessions": "ミーティングの録音がありません",
+ "meeting.noSessionsDesc": "「新規ミーティング」を押して録音を開始してください",
+ "meeting.summary": "要約",
+ "meeting.decisions": "決定事項",
+ "meeting.actionItems": "アクションアイテム",
+ "meeting.timeline": "タイムライン",
+ "meeting.transcript": "生のトランスクリプト",
+ "meeting.memos": "メモ",
+ "meeting.exportPdf": "PDFエクスポート",
+ "meeting.exportMarkdown": "Markdownエクスポート",
+ "meeting.delete": "削除",
+ "meeting.deleteConfirm": "このミーティング記録を削除しますか?",
+ "meeting.backToList": "一覧に戻る",
+ "meeting.notificationTitle": "議事録が完成しました",
+ "meeting.notificationBody": "ミーティングの議事録が生成されました。",
+ "meeting.sessions": "{{count}} セッション",
+ "meeting.duration": "{{minutes}} 分",
+ "meeting.editTitle": "タイトルを編集",
+ "meeting.untitled": "無題のミーティング",
+ "meeting.processingStep.merging": "トランスクリプトをマージ中...",
+ "meeting.processingStep.generating": "LLMで議事録を生成中...",
+ "meeting.processingStep.parsing": "議事録を解析中...",
+ "meeting.processingStep.saving": "保存中...",
+ "meeting.processingStep.notifying": "通知を送信中..."
}
diff --git a/src/renderer/i18n/ko.json b/src/renderer/i18n/ko.json
index 1b8990e..ec34515 100644
--- a/src/renderer/i18n/ko.json
+++ b/src/renderer/i18n/ko.json
@@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "로컬 AI 음성 어시스턴트",
-
"nav.dashboard": "대시보드",
"nav.history": "히스토리",
"nav.dictionary": "사전",
"nav.commands": "명령어",
"nav.settings": "설정",
-
"dashboard.sessionOverview": "세션 개요",
"dashboard.systemStatus": "시스템 상태",
"dashboard.sessionsToday": "오늘 세션",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}건",
"dashboard.stat": "통계",
"dashboard.sys": "시스템",
-
"history.title": "전사 기록",
"history.search": "검색...",
"history.entries": "{{count}}건",
@@ -34,7 +31,6 @@
"history.noResults": "검색 결과 없음",
"history.noHistory": "히스토리 없음 — 녹음을 시작하세요",
"history.count": "{{count}}건",
-
"dictionary.title": "커스텀 사전",
"dictionary.words": "{{count}}개",
"dictionary.add": "추가",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "단어 추가",
"dictionary.word": "단어",
"dictionary.pronunciation": "발음 (선택)",
-
"commands.title": "LLM 명령어",
"commands.count": "{{count}}개",
"commands.add": "추가",
@@ -62,14 +57,12 @@
"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": "누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다.",
@@ -89,20 +82,17 @@
"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": "(기본)",
@@ -110,7 +100,6 @@
"settings.insertMethod": "삽입 방식",
"settings.insertClipboard": "클립보드 (Ctrl+V)",
"settings.insertKeyboard": "키보드 타이핑",
-
"settings.whisperModel": "Whisper 모델",
"settings.model.tiny": "tiny (39 MB, 가장 빠름)",
"settings.model.base": "base (74 MB, 균형)",
@@ -119,7 +108,6 @@
"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).",
@@ -132,7 +120,6 @@
"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",
@@ -140,13 +127,11 @@
"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": "시작하기",
@@ -167,7 +152,6 @@
"onboarding.done.start": "시작하기",
"onboarding.back": "뒤로",
"onboarding.next": "다음",
-
"hotkey.title": "단축키 설정",
"hotkey.dictationTitle": "받아쓰기 단축키 설정",
"hotkey.oneTouchTitle": "원터치 모드 단축키 설정",
@@ -179,7 +163,6 @@
"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을 실행하는 무료 도구입니다.",
@@ -188,7 +171,6 @@
"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",
@@ -198,7 +180,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "취소",
"common.save": "저장",
"common.delete": "삭제",
@@ -210,7 +191,6 @@
"common.copy": "복사",
"common.test": "테스트",
"common.stop": "중지",
-
"memo.tags": "태그",
"memo.addTag": "태그 추가",
"memo.removeTag": "태그 제거",
@@ -221,7 +201,6 @@
"memo.filterByTag": "태그로 필터",
"memo.allTags": "전체 태그",
"memo.clearFilter": "필터 해제",
-
"voiceCommand.title": "음성 명령어",
"voiceCommand.enabled": "음성 키워드 인식",
"voiceCommand.enabledDesc": "전사 시작 시 키워드를 감지하여 명령어를 자동 선택합니다.",
@@ -232,7 +211,6 @@
"voiceCommand.matchMode.prefix": "앞부분 일치",
"voiceCommand.matchMode.suffix": "뒷부분 일치",
"voiceCommand.matchMode.contains": "포함",
-
"chain.title": "LLM 체인",
"chain.count": "{{count}}개",
"chain.add": "체인 추가",
@@ -251,24 +229,19 @@
"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",
@@ -343,7 +316,6 @@
"license.unlimited": "무제한",
"license.locked": "잠금",
"license.nav": "라이선스",
-
"fileTranscription.title": "파일 전사",
"fileTranscription.dropZone": "오디오/비디오 파일을 여기에 드래그하세요",
"fileTranscription.dropZoneHint": "MP3, WAV, M4A, MP4, MKV, WEBM 지원",
@@ -356,7 +328,6 @@
"fileTranscription.retry": "다시 시도",
"fileTranscription.error.invalidFormat": "지원하지 않는 파일 형식입니다",
"fileTranscription.error.unknown": "알 수 없는 오류",
-
"meetingSummary.title": "회의록 요약",
"meetingSummary.generating": "요약 생성 중...",
"meetingSummary.summary": "요약",
@@ -365,7 +336,6 @@
"meetingSummary.exportMarkdown": "마크다운 내보내기",
"meetingSummary.noSummary": "요약이 없습니다",
"meetingSummary.generate": "요약 생성",
-
"template.title": "딕테이션 템플릿",
"template.create": "새 템플릿",
"template.edit": "템플릿 편집",
@@ -381,7 +351,6 @@
"template.startSession": "시작",
"template.cancelSession": "세션 취소",
"template.empty": "템플릿이 없습니다",
-
"nav.conversation": "대화",
"conversation.title": "음성 대화",
"conversation.idle": "대기",
@@ -393,7 +362,6 @@
"conversation.inputPlaceholder": "메시지 입력...",
"conversation.end": "종료",
"conversation.clearHistory": "대화 초기화",
-
"nav.knowledge": "지식 베이스",
"rag.title": "지식 베이스",
"rag.addDocument": "문서 추가",
@@ -410,12 +378,56 @@
"rag.sources": "참조 문서",
"rag.adding": "추가 중...",
"rag.parsing": "문서 분석 중...",
-
"voiceAction.title": "음성 액션",
"voiceAction.execute": "실행",
"voiceAction.presets": "프리셋 명령",
"voiceAction.history": "실행 이력",
"voiceAction.noHistory": "실행 이력이 없습니다",
"voiceAction.blocked": "차단됨 (위험 명령)",
- "voiceAction.executed": "실행됨"
+ "voiceAction.executed": "실행됨",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "한국어",
+ "settings.sttLang.en": "English",
+ "settings.sttLang.ja": "日本語",
+ "settings.sttLang.zh": "中文",
+ "template.outputHelperText": "{{필드명}} 형식으로 플레이스홀더를 사용하세요",
+ "nav.meeting": "회의",
+ "meeting.title": "회의 모드",
+ "meeting.newMeeting": "새 회의",
+ "meeting.startRecording": "녹음 시작",
+ "meeting.stopRecording": "녹음 종료",
+ "meeting.recording": "녹음 중",
+ "meeting.processing": "회의록 생성 중...",
+ "meeting.completed": "완료",
+ "meeting.error": "오류",
+ "meeting.elapsed": "경과 시간",
+ "meeting.memoPlaceholder": "메모 입력 (Enter로 추가)",
+ "meeting.memoAdded": "메모 추가됨",
+ "meeting.noSessions": "회의 녹음이 없습니다",
+ "meeting.noSessionsDesc": "새 회의 버튼을 눌러 녹음을 시작하세요",
+ "meeting.summary": "요약",
+ "meeting.decisions": "결정사항",
+ "meeting.actionItems": "할 일",
+ "meeting.timeline": "타임라인",
+ "meeting.transcript": "원문 전사",
+ "meeting.memos": "메모",
+ "meeting.exportPdf": "PDF 내보내기",
+ "meeting.exportMarkdown": "마크다운 내보내기",
+ "meeting.delete": "삭제",
+ "meeting.deleteConfirm": "이 회의록을 삭제하시겠습니까?",
+ "meeting.backToList": "목록으로",
+ "meeting.notificationTitle": "회의록 준비 완료",
+ "meeting.notificationBody": "회의록이 생성되었습니다. 클릭하여 확인하세요.",
+ "meeting.sessions": "{{count}}건",
+ "meeting.duration": "{{minutes}}분",
+ "meeting.editTitle": "제목 편집",
+ "meeting.untitled": "무제 회의",
+ "meeting.processingStep.merging": "전사 텍스트 합산 중...",
+ "meeting.processingStep.generating": "LLM 회의록 생성 중...",
+ "meeting.processingStep.parsing": "회의록 파싱 중...",
+ "meeting.processingStep.saving": "저장 중...",
+ "meeting.processingStep.notifying": "완료 알림 중..."
}
diff --git a/src/renderer/i18n/pt.json b/src/renderer/i18n/pt.json
index 54fcadb..bee0cf4 100644
--- a/src/renderer/i18n/pt.json
+++ b/src/renderer/i18n/pt.json
@@ -1,13 +1,11 @@
{
"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",
@@ -26,7 +24,6 @@
"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",
@@ -34,7 +31,6 @@
"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",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "Adicionar palavra",
"dictionary.word": "Palavra",
"dictionary.pronunciation": "Pronúncia (opcional)",
-
"commands.title": "Comandos LLM",
"commands.count": "{{count}} comandos",
"commands.add": "Adicionar",
@@ -62,14 +57,12 @@
"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.",
@@ -82,20 +75,17 @@
"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)",
@@ -103,7 +93,6 @@
"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)",
@@ -112,7 +101,6 @@
"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).",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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",
@@ -160,7 +145,6 @@
"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",
@@ -171,7 +155,6 @@
"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.",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ATIVO",
"service.standby": "EM ESPERA",
-
"common.cancel": "Cancelar",
"common.save": "Salvar",
"common.delete": "Excluir",
@@ -202,7 +183,51 @@
"common.copy": "Copiar",
"common.test": "Testar",
"common.stop": "Parar",
-
"date.today": "Hoje",
- "date.yesterday": "Ontem"
+ "date.yesterday": "Ontem",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "Coreano",
+ "settings.sttLang.en": "Inglês",
+ "settings.sttLang.ja": "Japonês",
+ "settings.sttLang.zh": "Chinês",
+ "template.outputHelperText": "Use {{nomeCampo}} para marcadores de posição",
+ "nav.meeting": "Reunião",
+ "meeting.title": "Modo Reunião",
+ "meeting.newMeeting": "Nova Reunião",
+ "meeting.startRecording": "Iniciar Gravação",
+ "meeting.stopRecording": "Parar Gravação",
+ "meeting.recording": "Gravando",
+ "meeting.processing": "Gerando ata...",
+ "meeting.completed": "Concluído",
+ "meeting.error": "Erro",
+ "meeting.elapsed": "Tempo decorrido",
+ "meeting.memoPlaceholder": "Digite um memo (Enter para adicionar)",
+ "meeting.memoAdded": "Memo adicionado",
+ "meeting.noSessions": "Nenhuma gravação de reunião",
+ "meeting.noSessionsDesc": "Pressione Nova Reunião para começar a gravar",
+ "meeting.summary": "Resumo",
+ "meeting.decisions": "Decisões",
+ "meeting.actionItems": "Itens de ação",
+ "meeting.timeline": "Linha do tempo",
+ "meeting.transcript": "Transcrição bruta",
+ "meeting.memos": "Memos",
+ "meeting.exportPdf": "Exportar PDF",
+ "meeting.exportMarkdown": "Exportar Markdown",
+ "meeting.delete": "Excluir",
+ "meeting.deleteConfirm": "Excluir este registro de reunião?",
+ "meeting.backToList": "Voltar à lista",
+ "meeting.notificationTitle": "Ata pronta",
+ "meeting.notificationBody": "A ata da reunião foi gerada.",
+ "meeting.sessions": "{{count}} sessões",
+ "meeting.duration": "{{minutes}} min",
+ "meeting.editTitle": "Editar título",
+ "meeting.untitled": "Reunião sem título",
+ "meeting.processingStep.merging": "Mesclando transcrição...",
+ "meeting.processingStep.generating": "Gerando ata com LLM...",
+ "meeting.processingStep.parsing": "Analisando ata...",
+ "meeting.processingStep.saving": "Salvando...",
+ "meeting.processingStep.notifying": "Enviando notificação..."
}
diff --git a/src/renderer/i18n/ru.json b/src/renderer/i18n/ru.json
index a298e4f..ba4495f 100644
--- a/src/renderer/i18n/ru.json
+++ b/src/renderer/i18n/ru.json
@@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Локальный голосовой ИИ-ассистент",
-
"nav.dashboard": "Панель",
"nav.history": "История",
"nav.dictionary": "Словарь",
"nav.commands": "Команды",
"nav.settings": "Настройки",
-
"dashboard.sessionOverview": "Обзор сессий",
"dashboard.systemStatus": "Состояние системы",
"dashboard.sessionsToday": "Сессий сегодня",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} записей",
"dashboard.stat": "Статистика",
"dashboard.sys": "Система",
-
"history.title": "История транскрипций",
"history.search": "Поиск...",
"history.entries": "{{count}} записей",
@@ -34,7 +31,6 @@
"history.noResults": "Результаты не найдены",
"history.noHistory": "История пуста — начните запись",
"history.count": "{{count}} записей",
-
"dictionary.title": "Пользовательский словарь",
"dictionary.words": "{{count}} слов",
"dictionary.add": "Добавить",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "Добавить слово",
"dictionary.word": "Слово",
"dictionary.pronunciation": "Произношение (необязательно)",
-
"commands.title": "Команды LLM",
"commands.count": "{{count}} шт.",
"commands.add": "Добавить",
@@ -62,14 +57,12 @@
"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": "Говорите, удерживая клавишу. Отпустите для начала транскрипции.",
@@ -82,20 +75,17 @@
"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": "(По умолчанию)",
@@ -103,7 +93,6 @@
"settings.insertMethod": "Метод вставки",
"settings.insertClipboard": "Буфер обмена (Ctrl+V)",
"settings.insertKeyboard": "Эмуляция клавиатуры",
-
"settings.whisperModel": "Модель Whisper",
"settings.model.tiny": "tiny (39 МБ, самая быстрая)",
"settings.model.base": "base (74 МБ, баланс)",
@@ -112,7 +101,6 @@
"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).",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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": "Начать",
@@ -160,7 +145,6 @@
"onboarding.done.start": "Начать",
"onboarding.back": "Назад",
"onboarding.next": "Далее",
-
"hotkey.title": "Настройка горячей клавиши",
"hotkey.dictationTitle": "Задать горячую клавишу диктовки",
"hotkey.oneTouchTitle": "Задать горячую клавишу режима одного нажатия",
@@ -171,7 +155,6 @@
"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.",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "Отмена",
"common.save": "Сохранить",
"common.delete": "Удалить",
@@ -202,7 +183,51 @@
"common.copy": "Копировать",
"common.test": "Тест",
"common.stop": "Стоп",
-
"date.today": "Сегодня",
- "date.yesterday": "Вчера"
+ "date.yesterday": "Вчера",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "Корейский",
+ "settings.sttLang.en": "Английский",
+ "settings.sttLang.ja": "Японский",
+ "settings.sttLang.zh": "Китайский",
+ "template.outputHelperText": "Используйте {{имяПоля}} для заполнителей",
+ "nav.meeting": "Совещание",
+ "meeting.title": "Режим совещания",
+ "meeting.newMeeting": "Новое совещание",
+ "meeting.startRecording": "Начать запись",
+ "meeting.stopRecording": "Остановить запись",
+ "meeting.recording": "Запись",
+ "meeting.processing": "Создание протокола...",
+ "meeting.completed": "Завершено",
+ "meeting.error": "Ошибка",
+ "meeting.elapsed": "Прошло",
+ "meeting.memoPlaceholder": "Введите заметку (Enter для добавления)",
+ "meeting.memoAdded": "Заметка добавлена",
+ "meeting.noSessions": "Нет записей совещаний",
+ "meeting.noSessionsDesc": "Нажмите «Новое совещание», чтобы начать запись",
+ "meeting.summary": "Сводка",
+ "meeting.decisions": "Решения",
+ "meeting.actionItems": "Задачи",
+ "meeting.timeline": "Хронология",
+ "meeting.transcript": "Исходная расшифровка",
+ "meeting.memos": "Заметки",
+ "meeting.exportPdf": "Экспорт в PDF",
+ "meeting.exportMarkdown": "Экспорт в Markdown",
+ "meeting.delete": "Удалить",
+ "meeting.deleteConfirm": "Удалить эту запись совещания?",
+ "meeting.backToList": "Вернуться к списку",
+ "meeting.notificationTitle": "Протокол готов",
+ "meeting.notificationBody": "Протокол совещания сформирован.",
+ "meeting.sessions": "{{count}} сессий",
+ "meeting.duration": "{{minutes}} мин",
+ "meeting.editTitle": "Изменить название",
+ "meeting.untitled": "Совещание без названия",
+ "meeting.processingStep.merging": "Объединение расшифровки...",
+ "meeting.processingStep.generating": "Создание протокола с помощью LLM...",
+ "meeting.processingStep.parsing": "Разбор протокола...",
+ "meeting.processingStep.saving": "Сохранение...",
+ "meeting.processingStep.notifying": "Отправка уведомления..."
}
diff --git a/src/renderer/i18n/th.json b/src/renderer/i18n/th.json
index 99a4b60..cc71563 100644
--- a/src/renderer/i18n/th.json
+++ b/src/renderer/i18n/th.json
@@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "ผู้ช่วยเสียง AI ในเครื่อง",
-
"nav.dashboard": "แดชบอร์ด",
"nav.history": "ประวัติ",
"nav.dictionary": "พจนานุกรม",
"nav.commands": "คำสั่ง",
"nav.settings": "การตั้งค่า",
-
"dashboard.sessionOverview": "ภาพรวมเซสชัน",
"dashboard.systemStatus": "สถานะระบบ",
"dashboard.sessionsToday": "เซสชันวันนี้",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} รายการ",
"dashboard.stat": "สถิติ",
"dashboard.sys": "ระบบ",
-
"history.title": "ประวัติการถอดความ",
"history.search": "ค้นหา...",
"history.entries": "{{count}} รายการ",
@@ -34,7 +31,6 @@
"history.noResults": "ไม่พบผลลัพธ์",
"history.noHistory": "ไม่มีประวัติ — เริ่มบันทึกเสียง",
"history.count": "{{count}} รายการ",
-
"dictionary.title": "พจนานุกรมที่กำหนดเอง",
"dictionary.words": "{{count}} คำ",
"dictionary.add": "เพิ่ม",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "เพิ่มคำ",
"dictionary.word": "คำ",
"dictionary.pronunciation": "การออกเสียง (ไม่บังคับ)",
-
"commands.title": "คำสั่ง LLM",
"commands.count": "{{count}} รายการ",
"commands.add": "เพิ่ม",
@@ -62,14 +57,12 @@
"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": "กดค้างไว้แล้วพูด เมื่อปล่อยจะเริ่มถอดความ",
@@ -82,20 +75,17 @@
"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": "(ค่าเริ่มต้น)",
@@ -103,7 +93,6 @@
"settings.insertMethod": "วิธีการแทรก",
"settings.insertClipboard": "คลิปบอร์ด (Ctrl+V)",
"settings.insertKeyboard": "จำลองการพิมพ์",
-
"settings.whisperModel": "โมเดล Whisper",
"settings.model.tiny": "tiny (39 MB, เร็วที่สุด)",
"settings.model.base": "base (74 MB, สมดุล)",
@@ -112,7 +101,6 @@
"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)",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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": "เริ่มต้น",
@@ -160,7 +145,6 @@
"onboarding.done.start": "เริ่มต้น",
"onboarding.back": "ย้อนกลับ",
"onboarding.next": "ถัดไป",
-
"hotkey.title": "การตั้งค่าปุ่มลัด",
"hotkey.dictationTitle": "ตั้งค่าปุ่มลัดการบอกเล่า",
"hotkey.oneTouchTitle": "ตั้งค่าปุ่มลัดโหมดสัมผัสเดียว",
@@ -171,7 +155,6 @@
"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 ในเครื่อง",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "ยกเลิก",
"common.save": "บันทึก",
"common.delete": "ลบ",
@@ -202,7 +183,51 @@
"common.copy": "คัดลอก",
"common.test": "ทดสอบ",
"common.stop": "หยุด",
-
"date.today": "วันนี้",
- "date.yesterday": "เมื่อวาน"
+ "date.yesterday": "เมื่อวาน",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "เกาหลี",
+ "settings.sttLang.en": "อังกฤษ",
+ "settings.sttLang.ja": "ญี่ปุ่น",
+ "settings.sttLang.zh": "จีน",
+ "template.outputHelperText": "ใช้ {{ชื่อฟิลด์}} สำหรับตัวยึดตำแหน่ง",
+ "nav.meeting": "การประชุม",
+ "meeting.title": "โหมดการประชุม",
+ "meeting.newMeeting": "การประชุมใหม่",
+ "meeting.startRecording": "เริ่มบันทึก",
+ "meeting.stopRecording": "หยุดบันทึก",
+ "meeting.recording": "กำลังบันทึก",
+ "meeting.processing": "กำลังสร้างรายงานการประชุม...",
+ "meeting.completed": "เสร็จสิ้น",
+ "meeting.error": "ข้อผิดพลาด",
+ "meeting.elapsed": "เวลาที่ผ่านไป",
+ "meeting.memoPlaceholder": "พิมพ์บันทึก (Enter เพื่อเพิ่ม)",
+ "meeting.memoAdded": "เพิ่มบันทึกแล้ว",
+ "meeting.noSessions": "ไม่มีการบันทึกการประชุม",
+ "meeting.noSessionsDesc": "กด «การประชุมใหม่» เพื่อเริ่มบันทึก",
+ "meeting.summary": "สรุป",
+ "meeting.decisions": "การตัดสินใจ",
+ "meeting.actionItems": "รายการดำเนินการ",
+ "meeting.timeline": "ไทม์ไลน์",
+ "meeting.transcript": "ถอดความดิบ",
+ "meeting.memos": "บันทึก",
+ "meeting.exportPdf": "ส่งออก PDF",
+ "meeting.exportMarkdown": "ส่งออก Markdown",
+ "meeting.delete": "ลบ",
+ "meeting.deleteConfirm": "ลบรายงานการประชุมนี้?",
+ "meeting.backToList": "กลับไปยังรายการ",
+ "meeting.notificationTitle": "รายงานการประชุมพร้อมแล้ว",
+ "meeting.notificationBody": "สร้างรายงานการประชุมเสร็จแล้ว",
+ "meeting.sessions": "{{count}} เซสชัน",
+ "meeting.duration": "{{minutes}} นาที",
+ "meeting.editTitle": "แก้ไขชื่อ",
+ "meeting.untitled": "การประชุมที่ไม่มีชื่อ",
+ "meeting.processingStep.merging": "กำลังรวมการถอดความ...",
+ "meeting.processingStep.generating": "กำลังสร้างรายงานด้วย LLM...",
+ "meeting.processingStep.parsing": "กำลังวิเคราะห์รายงาน...",
+ "meeting.processingStep.saving": "กำลังบันทึก...",
+ "meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน..."
}
diff --git a/src/renderer/i18n/vi.json b/src/renderer/i18n/vi.json
index 0220c7d..1a5c80f 100644
--- a/src/renderer/i18n/vi.json
+++ b/src/renderer/i18n/vi.json
@@ -1,13 +1,11 @@
{
"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",
@@ -26,7 +24,6 @@
"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",
@@ -34,7 +31,6 @@
"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",
@@ -47,7 +43,6 @@
"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",
@@ -62,14 +57,12 @@
"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.",
@@ -82,20 +75,17 @@
"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)",
@@ -103,7 +93,6 @@
"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)",
@@ -112,7 +101,6 @@
"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).",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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",
@@ -160,7 +145,6 @@
"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",
@@ -171,7 +155,6 @@
"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ộ.",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "Hủy",
"common.save": "Lưu",
"common.delete": "Xóa",
@@ -202,7 +183,51 @@
"common.copy": "Sao chép",
"common.test": "Kiểm tra",
"common.stop": "Dừng",
-
"date.today": "Hôm nay",
- "date.yesterday": "Hôm qua"
+ "date.yesterday": "Hôm qua",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "Tiếng Hàn",
+ "settings.sttLang.en": "Tiếng Anh",
+ "settings.sttLang.ja": "Tiếng Nhật",
+ "settings.sttLang.zh": "Tiếng Trung",
+ "template.outputHelperText": "Sử dụng {{tênTrường}} cho trình giữ chỗ",
+ "nav.meeting": "Cuộc họp",
+ "meeting.title": "Chế độ Cuộc họp",
+ "meeting.newMeeting": "Cuộc họp mới",
+ "meeting.startRecording": "Bắt đầu ghi âm",
+ "meeting.stopRecording": "Dừng ghi âm",
+ "meeting.recording": "Đang ghi âm",
+ "meeting.processing": "Đang tạo biên bản...",
+ "meeting.completed": "Hoàn thành",
+ "meeting.error": "Lỗi",
+ "meeting.elapsed": "Thời gian đã qua",
+ "meeting.memoPlaceholder": "Nhập ghi chú (Enter để thêm)",
+ "meeting.memoAdded": "Đã thêm ghi chú",
+ "meeting.noSessions": "Không có bản ghi cuộc họp",
+ "meeting.noSessionsDesc": "Nhấn «Cuộc họp mới» để bắt đầu ghi âm",
+ "meeting.summary": "Tóm tắt",
+ "meeting.decisions": "Quyết định",
+ "meeting.actionItems": "Hành động cần thực hiện",
+ "meeting.timeline": "Dòng thời gian",
+ "meeting.transcript": "Bản ghi thô",
+ "meeting.memos": "Ghi chú",
+ "meeting.exportPdf": "Xuất PDF",
+ "meeting.exportMarkdown": "Xuất Markdown",
+ "meeting.delete": "Xóa",
+ "meeting.deleteConfirm": "Xóa bản ghi cuộc họp này?",
+ "meeting.backToList": "Quay lại danh sách",
+ "meeting.notificationTitle": "Biên bản đã sẵn sàng",
+ "meeting.notificationBody": "Biên bản cuộc họp đã được tạo.",
+ "meeting.sessions": "{{count}} phiên",
+ "meeting.duration": "{{minutes}} phút",
+ "meeting.editTitle": "Chỉnh sửa tiêu đề",
+ "meeting.untitled": "Cuộc họp không có tiêu đề",
+ "meeting.processingStep.merging": "Đang ghép bản ghi...",
+ "meeting.processingStep.generating": "Đang tạo biên bản với LLM...",
+ "meeting.processingStep.parsing": "Đang phân tích biên bản...",
+ "meeting.processingStep.saving": "Đang lưu...",
+ "meeting.processingStep.notifying": "Đang gửi thông báo..."
}
diff --git a/src/renderer/i18n/zh-TW.json b/src/renderer/i18n/zh-TW.json
index 48036a0..d9786e1 100644
--- a/src/renderer/i18n/zh-TW.json
+++ b/src/renderer/i18n/zh-TW.json
@@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "本地AI語音助手",
-
"nav.dashboard": "儀表板",
"nav.history": "歷史記錄",
"nav.dictionary": "辭典",
"nav.commands": "指令",
"nav.settings": "設定",
-
"dashboard.sessionOverview": "工作階段總覽",
"dashboard.systemStatus": "系統狀態",
"dashboard.sessionsToday": "今日工作階段",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}筆",
"dashboard.stat": "統計",
"dashboard.sys": "系統",
-
"history.title": "轉錄歷史",
"history.search": "搜尋...",
"history.entries": "{{count}}筆",
@@ -34,7 +31,6 @@
"history.noResults": "無搜尋結果",
"history.noHistory": "尚無歷史記錄 — 請開始錄音",
"history.count": "{{count}}筆",
-
"dictionary.title": "自訂辭典",
"dictionary.words": "{{count}}個詞",
"dictionary.add": "新增",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "新增詞條",
"dictionary.word": "詞彙",
"dictionary.pronunciation": "發音(選填)",
-
"commands.title": "LLM指令",
"commands.count": "{{count}}個",
"commands.add": "新增",
@@ -62,14 +57,12 @@
"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": "按住鍵開始說話,放開鍵後開始轉錄。",
@@ -82,20 +75,17 @@
"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": "(預設)",
@@ -103,7 +93,6 @@
"settings.insertMethod": "插入方式",
"settings.insertClipboard": "剪貼簿(Ctrl+V)",
"settings.insertKeyboard": "鍵盤輸入",
-
"settings.whisperModel": "Whisper模型",
"settings.model.tiny": "tiny(39 MB,最快)",
"settings.model.base": "base(74 MB,均衡)",
@@ -112,7 +101,6 @@
"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)。",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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": "開始",
@@ -160,7 +145,6 @@
"onboarding.done.start": "開始",
"onboarding.back": "返回",
"onboarding.next": "下一步",
-
"hotkey.title": "快速鍵設定",
"hotkey.dictationTitle": "設定聽寫快速鍵",
"hotkey.oneTouchTitle": "設定一鍵模式快速鍵",
@@ -171,7 +155,6 @@
"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的免費工具。",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "取消",
"common.save": "儲存",
"common.delete": "刪除",
@@ -202,7 +183,51 @@
"common.copy": "複製",
"common.test": "測試",
"common.stop": "停止",
-
"date.today": "今天",
- "date.yesterday": "昨天"
+ "date.yesterday": "昨天",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "韓語",
+ "settings.sttLang.en": "English",
+ "settings.sttLang.ja": "日語",
+ "settings.sttLang.zh": "中文",
+ "template.outputHelperText": "使用 {{欄位名稱}} 作為佔位符",
+ "nav.meeting": "會議",
+ "meeting.title": "會議模式",
+ "meeting.newMeeting": "新增會議",
+ "meeting.startRecording": "開始錄音",
+ "meeting.stopRecording": "停止錄音",
+ "meeting.recording": "錄音中",
+ "meeting.processing": "正在產生會議記錄...",
+ "meeting.completed": "已完成",
+ "meeting.error": "錯誤",
+ "meeting.elapsed": "已用時",
+ "meeting.memoPlaceholder": "輸入備忘(Enter 新增)",
+ "meeting.memoAdded": "備忘已新增",
+ "meeting.noSessions": "暫無會議錄音",
+ "meeting.noSessionsDesc": "點擊「新增會議」開始錄音",
+ "meeting.summary": "摘要",
+ "meeting.decisions": "決議",
+ "meeting.actionItems": "待辦事項",
+ "meeting.timeline": "時間軸",
+ "meeting.transcript": "原始逐字稿",
+ "meeting.memos": "備忘",
+ "meeting.exportPdf": "匯出 PDF",
+ "meeting.exportMarkdown": "匯出 Markdown",
+ "meeting.delete": "刪除",
+ "meeting.deleteConfirm": "刪除此會議記錄?",
+ "meeting.backToList": "返回列表",
+ "meeting.notificationTitle": "會議記錄已就緒",
+ "meeting.notificationBody": "會議記錄已產生。",
+ "meeting.sessions": "{{count}} 場會議",
+ "meeting.duration": "{{minutes}} 分鐘",
+ "meeting.editTitle": "編輯標題",
+ "meeting.untitled": "無標題會議",
+ "meeting.processingStep.merging": "正在合併逐字稿...",
+ "meeting.processingStep.generating": "正在以 LLM 產生會議記錄...",
+ "meeting.processingStep.parsing": "正在解析會議記錄...",
+ "meeting.processingStep.saving": "正在儲存...",
+ "meeting.processingStep.notifying": "正在發送通知..."
}
diff --git a/src/renderer/i18n/zh.json b/src/renderer/i18n/zh.json
index 385dd8b..0ae6cbd 100644
--- a/src/renderer/i18n/zh.json
+++ b/src/renderer/i18n/zh.json
@@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "本地AI语音助手",
-
"nav.dashboard": "仪表板",
"nav.history": "历史记录",
"nav.dictionary": "词典",
"nav.commands": "命令",
"nav.settings": "设置",
-
"dashboard.sessionOverview": "会话概览",
"dashboard.systemStatus": "系统状态",
"dashboard.sessionsToday": "今日会话",
@@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}条",
"dashboard.stat": "统计",
"dashboard.sys": "系统",
-
"history.title": "转录历史",
"history.search": "搜索...",
"history.entries": "{{count}}条",
@@ -34,7 +31,6 @@
"history.noResults": "无搜索结果",
"history.noHistory": "暂无历史记录 — 请开始录音",
"history.count": "{{count}}条",
-
"dictionary.title": "自定义词典",
"dictionary.words": "{{count}}个词",
"dictionary.add": "添加",
@@ -47,7 +43,6 @@
"dictionary.addTitle": "添加词条",
"dictionary.word": "词汇",
"dictionary.pronunciation": "发音(可选)",
-
"commands.title": "LLM命令",
"commands.count": "{{count}}个",
"commands.add": "添加",
@@ -62,14 +57,12 @@
"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": "按住键开始说话,松开键后开始转录。",
@@ -82,20 +75,17 @@
"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": "(默认)",
@@ -103,7 +93,6 @@
"settings.insertMethod": "插入方式",
"settings.insertClipboard": "剪贴板(Ctrl+V)",
"settings.insertKeyboard": "键盘输入",
-
"settings.whisperModel": "Whisper模型",
"settings.model.tiny": "tiny(39 MB,最快)",
"settings.model.base": "base(74 MB,均衡)",
@@ -112,7 +101,6 @@
"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)。",
@@ -125,7 +113,6 @@
"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",
@@ -133,13 +120,11 @@
"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": "开始",
@@ -160,7 +145,6 @@
"onboarding.done.start": "开始",
"onboarding.back": "返回",
"onboarding.next": "下一步",
-
"hotkey.title": "快捷键设置",
"hotkey.dictationTitle": "设置听写快捷键",
"hotkey.oneTouchTitle": "设置一触即发模式快捷键",
@@ -171,7 +155,6 @@
"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的免费工具。",
@@ -180,7 +163,6 @@
"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",
@@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
-
"common.cancel": "取消",
"common.save": "保存",
"common.delete": "删除",
@@ -202,7 +183,51 @@
"common.copy": "复制",
"common.test": "测试",
"common.stop": "停止",
-
"date.today": "今天",
- "date.yesterday": "昨天"
+ "date.yesterday": "昨天",
+ "settings.theme.nord": "Nord",
+ "settings.theme.solarized": "Solarized",
+ "settings.theme.catppuccin": "Catppuccin",
+ "settings.theme.dracula": "Dracula",
+ "settings.sttLang.ko": "韩语",
+ "settings.sttLang.en": "English",
+ "settings.sttLang.ja": "日语",
+ "settings.sttLang.zh": "中文",
+ "template.outputHelperText": "使用 {{字段名}} 作为占位符",
+ "nav.meeting": "会议",
+ "meeting.title": "会议模式",
+ "meeting.newMeeting": "新建会议",
+ "meeting.startRecording": "开始录音",
+ "meeting.stopRecording": "停止录音",
+ "meeting.recording": "录音中",
+ "meeting.processing": "正在生成会议纪要...",
+ "meeting.completed": "已完成",
+ "meeting.error": "错误",
+ "meeting.elapsed": "已用时",
+ "meeting.memoPlaceholder": "输入备忘(Enter 添加)",
+ "meeting.memoAdded": "备忘已添加",
+ "meeting.noSessions": "暂无会议录音",
+ "meeting.noSessionsDesc": "点击「新建会议」开始录音",
+ "meeting.summary": "摘要",
+ "meeting.decisions": "决议",
+ "meeting.actionItems": "待办事项",
+ "meeting.timeline": "时间轴",
+ "meeting.transcript": "原始转录",
+ "meeting.memos": "备忘",
+ "meeting.exportPdf": "导出 PDF",
+ "meeting.exportMarkdown": "导出 Markdown",
+ "meeting.delete": "删除",
+ "meeting.deleteConfirm": "删除此会议记录?",
+ "meeting.backToList": "返回列表",
+ "meeting.notificationTitle": "会议纪要已就绪",
+ "meeting.notificationBody": "会议纪要已生成。",
+ "meeting.sessions": "{{count}} 场会议",
+ "meeting.duration": "{{minutes}} 分钟",
+ "meeting.editTitle": "编辑标题",
+ "meeting.untitled": "无标题会议",
+ "meeting.processingStep.merging": "正在合并转录...",
+ "meeting.processingStep.generating": "正在用 LLM 生成会议纪要...",
+ "meeting.processingStep.parsing": "正在解析会议纪要...",
+ "meeting.processingStep.saving": "正在保存...",
+ "meeting.processingStep.notifying": "正在发送通知..."
}
diff --git a/src/renderer/pages/MeetingModePage.tsx b/src/renderer/pages/MeetingModePage.tsx
new file mode 100644
index 0000000..8178b10
--- /dev/null
+++ b/src/renderer/pages/MeetingModePage.tsx
@@ -0,0 +1,570 @@
+// src/renderer/pages/MeetingModePage.tsx
+// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import {
+ Box,
+ TextField,
+ IconButton,
+ Tooltip,
+ LinearProgress,
+ Typography,
+} from '@mui/material'
+import AddIcon from '@mui/icons-material/Add'
+import StopIcon from '@mui/icons-material/Stop'
+import ArrowBackIcon from '@mui/icons-material/ArrowBack'
+import DeleteIcon from '@mui/icons-material/Delete'
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
+import DescriptionIcon from '@mui/icons-material/Description'
+import EditIcon from '@mui/icons-material/Edit'
+import CheckIcon from '@mui/icons-material/Check'
+import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
+import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
+import { PageHeader, EmptyStateCard } from '../components/shared'
+import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
+import { useI18n } from '../i18n'
+import type {
+ MeetingSessionSummary,
+ MeetingSessionDetail,
+ MeetingModeStateInfo,
+ MeetingProcessingProgress,
+ CaptionSegment,
+ MeetingMemo,
+} from '@shared/types'
+
+type MeetingView = 'list' | 'recording' | 'detail'
+
+export function MeetingModePage(): React.ReactElement {
+ const { t } = useI18n()
+ const [view, setView] = useState('list')
+ const [sessions, setSessions] = useState([])
+ const [totalSessions, setTotalSessions] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [page, setPage] = useState(1)
+ const pageSize = 20
+
+ // 녹음 뷰 상태
+ const [stateInfo, setStateInfo] = useState(null)
+ const [segments, setSegments] = useState([])
+ const [memos, setMemos] = useState([])
+ const [memoInput, setMemoInput] = useState('')
+ const [elapsedMs, setElapsedMs] = useState(0)
+ const [progress, setProgress] = useState(null)
+ const transcriptRef = useRef(null)
+ const elapsedTimerRef = useRef | null>(null)
+ const recordingStartRef = useRef(0)
+
+ // 상세 뷰 상태
+ const [detail, setDetail] = useState(null)
+ const [editingTitle, setEditingTitle] = useState(false)
+ const [titleDraft, setTitleDraft] = useState('')
+
+ // ── 세션 목록 로드 ──
+ const loadSessions = useCallback(async () => {
+ setLoading(true)
+ const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize })
+ if (resp.success) {
+ setSessions(resp.data.sessions)
+ setTotalSessions(resp.data.total)
+ }
+ setLoading(false)
+ }, [page])
+
+ useEffect(() => {
+ if (view === 'list') loadSessions()
+ }, [view, loadSessions])
+
+ // ── IPC 이벤트 구독 ──
+ useEffect(() => {
+ const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
+ setStateInfo(info)
+ if (info.state === 'idle' && view === 'recording') {
+ // 후처리 완료 → 목록으로 이동
+ if (!progress) {
+ setView('list')
+ }
+ }
+ })
+
+ const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
+ setSegments((prev) => [...prev, seg])
+ // 자동 스크롤
+ setTimeout(() => {
+ if (transcriptRef.current) {
+ transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight
+ }
+ }, 50)
+ })
+
+ const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => {
+ setProgress(p)
+ })
+
+ const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => {
+ setProgress(null)
+ setView('list')
+ })
+
+ const unsubError = window.electronAPI.meetingMode.onError(() => {
+ setProgress(null)
+ setView('list')
+ })
+
+ return () => {
+ unsubState()
+ unsubSegment()
+ unsubProgress()
+ unsubCompleted()
+ unsubError()
+ }
+ }, [view, progress])
+
+ // ── 경과 시간 타이머 ──
+ useEffect(() => {
+ if (view === 'recording') {
+ recordingStartRef.current = Date.now()
+ elapsedTimerRef.current = setInterval(() => {
+ setElapsedMs(Date.now() - recordingStartRef.current)
+ }, 1000)
+ }
+ return () => {
+ if (elapsedTimerRef.current) {
+ clearInterval(elapsedTimerRef.current)
+ elapsedTimerRef.current = null
+ }
+ }
+ }, [view])
+
+ // ── 핸들러 ──
+ const handleStartRecording = useCallback(async () => {
+ const resp = await window.electronAPI.meetingMode.startRecording()
+ if (resp.success) {
+ setSegments([])
+ setMemos([])
+ setElapsedMs(0)
+ setProgress(null)
+ setView('recording')
+ }
+ }, [])
+
+ const handleStopRecording = useCallback(async () => {
+ await window.electronAPI.meetingMode.stopRecording()
+ }, [])
+
+ const handleAddMemo = useCallback(async () => {
+ if (!memoInput.trim()) return
+ const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() })
+ if (resp.success) {
+ setMemos((prev) => [...prev, resp.data])
+ setMemoInput('')
+ }
+ }, [memoInput])
+
+ const handleMemoKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleAddMemo()
+ }
+ },
+ [handleAddMemo],
+ )
+
+ const handleViewDetail = useCallback(async (sessionId: string) => {
+ const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
+ if (resp.success) {
+ setDetail(resp.data)
+ setView('detail')
+ }
+ }, [])
+
+ const handleDelete = useCallback(async () => {
+ if (!detail) return
+ await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id })
+ setDetail(null)
+ setView('list')
+ }, [detail])
+
+ const handleExportPdf = useCallback(async () => {
+ if (!detail) return
+ await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id })
+ }, [detail])
+
+ const handleExportMarkdown = useCallback(async () => {
+ if (!detail) return
+ await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id })
+ }, [detail])
+
+ const handleSaveTitle = useCallback(async () => {
+ if (!detail || !titleDraft.trim()) return
+ await window.electronAPI.meetingMode.updateTitle({
+ sessionId: detail.id,
+ title: titleDraft.trim(),
+ })
+ setDetail({ ...detail, title: titleDraft.trim() })
+ setEditingTitle(false)
+ }, [detail, titleDraft])
+
+ // ── 유틸 ──
+ const formatTime = (ms: number): string => {
+ const totalSec = Math.floor(ms / 1000)
+ const min = Math.floor(totalSec / 60)
+ const sec = totalSec % 60
+ return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
+ }
+
+ const formatDate = (ts: number): string => {
+ const d = new Date(ts)
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+ }
+
+ const statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => {
+ switch (status) {
+ case 'completed': return 'green'
+ case 'recording': return 'red'
+ case 'processing': return 'amber'
+ case 'error': return 'red'
+ default: return 'blue'
+ }
+ }
+
+ // ── 렌더: 목록 뷰 ──
+ if (view === 'list') {
+ return (
+
+ 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
+ action={
+
+
+ {t('meeting.newMeeting')}
+
+ }
+ />
+
+ {loading ? (
+
+ ) : sessions.length === 0 ? (
+
+ ) : (
+
+ {sessions.map((session) => (
+ handleViewDetail(session.id)}
+ >
+
+
+
+ {session.title ?? t('meeting.untitled')}
+
+
+ {session.durationMs != null
+ ? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
+ : '—'}
+ {' · '}
+ {t('meeting.memos')}: {session.memoCount}
+
+
+
+ {formatDate(session.startedAt)}
+
+
+
+
+ {/* 프로세싱 중인 세션 표시 */}
+ {session.status === 'processing' && progress && progress.sessionId === session.id && (
+
+
+
+ {t(`meeting.processingStep.${progress.step}` as Parameters[0])}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+ }
+
+ // ── 렌더: 녹음 뷰 ──
+ if (view === 'recording') {
+ const isProcessing = stateInfo?.state === 'processing'
+
+ return (
+
+ {/* 상단 바 */}
+
+
+
+
+ {formatTime(elapsedMs)}
+
+
+ {isProcessing ? t('meeting.processing') : t('meeting.recording')}
+
+
+ {!isProcessing && (
+
+
+ {t('meeting.stopRecording')}
+
+ )}
+
+
+ {/* 프로세싱 진행률 */}
+ {isProcessing && progress && (
+
+
+
+ {t(`meeting.processingStep.${progress.step}` as Parameters[0])} ({progress.percent}%)
+
+
+ )}
+
+ {/* 메인 패널: 전사 + 메모 */}
+
+ {/* 좌: 실시간 전사 */}
+
+
+ {t('meeting.transcript')}
+
+
+ {segments.map((seg) => (
+
+
+ [{formatTime(seg.timestamp)}]
+
+
+ {seg.text}
+
+
+ ))}
+
+
+
+ {/* 우: 메모 입력 */}
+
+
+ {t('meeting.memos')}
+
+
+ {memos.map((memo) => (
+
+
+ {formatTime(memo.timestampMs)}
+
+
+ {memo.content}
+
+
+ ))}
+
+ {!isProcessing && (
+ setMemoInput(e.target.value)}
+ onKeyDown={handleMemoKeyDown}
+ sx={{
+ '& .MuiInputBase-root': {
+ fontFamily: d3roFontMono,
+ fontSize: 13,
+ },
+ }}
+ />
+ )}
+
+
+
+ )
+ }
+
+ // ── 렌더: 상세 뷰 ──
+ if (view === 'detail' && detail) {
+ const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
+
+ return (
+
+ {/* 헤더 */}
+
+ { setDetail(null); setView('list') }}>
+
+
+ {editingTitle ? (
+
+ setTitleDraft(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
+ sx={{ flex: 1 }}
+ autoFocus
+ />
+
+
+
+
+ ) : (
+ <>
+
+ {detail.title ?? t('meeting.untitled')}
+
+
+ { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
+ >
+
+
+
+ >
+ )}
+
+
+ {/* 메타 */}
+
+ {formatDate(detail.startedAt)}
+ {detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
+ {' · '}
+ {t('meeting.duration', { minutes: durationMin })}
+
+
+ {/* 회의록 본문 */}
+ {detail.minutes && (
+ <>
+ {/* 요약 */}
+
+ {t('meeting.summary')}
+
+ {detail.minutes.summary}
+
+
+
+ {/* 결정사항 */}
+ {detail.minutes.decisions.length > 0 && (
+
+ {t('meeting.decisions')}
+ {detail.minutes.decisions.map((d, i) => (
+
+ {'• '}{d}
+
+ ))}
+
+ )}
+
+ {/* 할 일 */}
+ {detail.minutes.actionItems.length > 0 && (
+
+ {t('meeting.actionItems')}
+ {detail.minutes.actionItems.map((item, i) => (
+
+ {'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
+ {item.deadline ? ` (${item.deadline})` : ''}
+
+ ))}
+
+ )}
+
+ {/* 타임라인 */}
+ {detail.minutes.timeline.length > 0 && (
+
+ {t('meeting.timeline')}
+ {detail.minutes.timeline.map((tl, i) => (
+
+
+ {tl.time}
+
+
+ {tl.type === 'memo' ? '📝 ' : ''}{tl.content}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {/* 에러 메시지 */}
+ {detail.status === 'error' && detail.errorMessage && (
+
+
+ {t('meeting.error')}
+
+ {detail.errorMessage}
+
+ )}
+
+ {/* 메모 */}
+ {detail.memos.length > 0 && (
+
+ {t('meeting.memos')}
+ {detail.memos.map((m) => (
+
+
+ {formatTime(m.timestampMs)}
+
+ {m.content}
+
+ ))}
+
+ )}
+
+ {/* 액션 버튼 */}
+
+
+
+
+ {t('meeting.exportPdf')}
+
+
+
+
+
+ {t('meeting.exportMarkdown')}
+
+
+
+
+
+ {t('meeting.delete')}
+
+
+
+
+ )
+ }
+
+ return
+}
diff --git a/src/shared/errors.ts b/src/shared/errors.ts
index ab20cd9..275031c 100644
--- a/src/shared/errors.ts
+++ b/src/shared/errors.ts
@@ -151,6 +151,14 @@ export enum ErrorCode {
VoiceActionBlocked = 877,
VoiceActionInvalidPlan = 878,
+ // === Phase 14: Meeting Mode (880-889) ===
+ MeetingAlreadyRecording = 880,
+ MeetingNotRecording = 881,
+ MeetingProcessingFailed = 882,
+ MeetingSessionNotFound = 883,
+ MeetingExportFailed = 884,
+ MeetingPdfFailed = 885,
+
// === Config (800-849) ===
ConfigReadFailed = 800,
ConfigWriteFailed = 801,
diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts
index 04309de..37606c1 100644
--- a/src/shared/ipc-channels.ts
+++ b/src/shared/ipc-channels.ts
@@ -305,6 +305,26 @@ export const IPC_CHANNELS = {
ACTION_ERROR: 'voiceAction:actionError',
},
+ // ── Phase 14: Meeting Mode ──
+ MEETING_MODE: {
+ START_RECORDING: 'meetingMode:startRecording',
+ STOP_RECORDING: 'meetingMode:stopRecording',
+ ADD_MEMO: 'meetingMode:addMemo',
+ GET_STATE: 'meetingMode:getState',
+ GET_SESSIONS: 'meetingMode:getSessions',
+ GET_SESSION: 'meetingMode:getSession',
+ DELETE_SESSION: 'meetingMode:deleteSession',
+ UPDATE_TITLE: 'meetingMode:updateTitle',
+ EXPORT_PDF: 'meetingMode:exportPdf',
+ EXPORT_MARKDOWN: 'meetingMode:exportMarkdown',
+ // Main → Renderer events
+ STATE_CHANGED: 'meetingMode:stateChanged',
+ SEGMENT: 'meetingMode:segment',
+ PROCESSING_PROGRESS: 'meetingMode:processingProgress',
+ SESSION_COMPLETED: 'meetingMode:sessionCompleted',
+ ERROR: 'meetingMode:error',
+ },
+
// ── Phase 11: License & Monetization ──
LICENSE: {
GET_INFO: 'license:getInfo',
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 79e34c6..403eb1c 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -1262,3 +1262,99 @@ export interface VoiceActionErrorEvent {
message: string
userText: string
}
+
+// ============================================================
+// Phase 14: Meeting Mode
+// ============================================================
+
+export type MeetingModeState = 'idle' | 'recording' | 'processing' | 'completed' | 'error'
+
+export interface MeetingMemo {
+ id: string
+ sessionId: string
+ content: string
+ /** 녹음 시작 기준 상대 타임스탬프 (ms) */
+ timestampMs: number
+ createdAt: number
+}
+
+export interface MeetingSessionSummary {
+ id: string
+ title: string | null
+ status: 'recording' | 'processing' | 'completed' | 'error'
+ startedAt: number
+ endedAt: number | null
+ durationMs: number | null
+ memoCount: number
+}
+
+export interface MeetingSessionDetail extends MeetingSessionSummary {
+ rawTranscript: string | null
+ minutesMarkdown: string | null
+ minutes: MeetingMinutes | null
+ memos: MeetingMemo[]
+ sttModel: string | null
+ llmModel: string | null
+ errorMessage: string | null
+}
+
+export interface MeetingMinutes {
+ summary: string
+ decisions: string[]
+ actionItems: Array<{ task: string; assignee?: string; deadline?: string }>
+ timeline: Array<{ time: string; content: string; type: 'transcript' | 'memo' }>
+}
+
+export interface MeetingSessionPage {
+ sessions: MeetingSessionSummary[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+export interface MeetingStartResult {
+ sessionId: string
+}
+
+export interface MeetingAddMemoParams {
+ content: string
+}
+
+export interface MeetingGetSessionsParams {
+ page: number
+ pageSize: number
+}
+
+export interface MeetingGetSessionParams {
+ sessionId: string
+}
+
+export interface MeetingDeleteSessionParams {
+ sessionId: string
+}
+
+export interface MeetingExportParams {
+ sessionId: string
+}
+
+export interface MeetingModeStateInfo {
+ state: MeetingModeState
+ sessionId: string | null
+ elapsedMs: number
+ memoCount: number
+ segmentCount: number
+}
+
+export type MeetingProcessingStep =
+ | 'merging'
+ | 'generating'
+ | 'parsing'
+ | 'saving'
+ | 'notifying'
+
+export interface MeetingProcessingProgress {
+ sessionId: string
+ step: MeetingProcessingStep
+ percent: number
+}
diff --git a/tests/main/services/VoiceModeService.test.ts b/tests/main/services/VoiceModeService.test.ts
index 50ac290..40624ce 100644
--- a/tests/main/services/VoiceModeService.test.ts
+++ b/tests/main/services/VoiceModeService.test.ts
@@ -119,11 +119,9 @@ describe('VoiceModeService', () => {
const svc = getVoiceModeService()
expect(svc.isActive).toBe(false)
- // startSession은 비동기이므로 STT init이 resolve되면 세션 활성
- const promise = svc.startSession('dictation')
- expect(svc.isActive).toBe(true) // PREPARING 상태
-
- await promise
+ // startSession은 완전 비동기이므로 await 후 세션 활성 확인
+ await svc.startSession('dictation')
+ expect(svc.isActive).toBe(true)
})
})