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 {t('settings.theme.system')} {t('settings.theme.light')} {t('settings.theme.dark')} - Nord - Solarized - Catppuccin - Dracula + {t('settings.theme.nord')} + {t('settings.theme.solarized')} + {t('settings.theme.catppuccin')} + {t('settings.theme.dracula')} @@ -627,10 +627,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac onChange={(e) => updateConfig('sttLanguage', e.target.value)} > {t('settings.sttLang.auto')} - 한국어 - English - 日本語 - 中文 + {t('settings.sttLang.ko')} + {t('settings.sttLang.en')} + {t('settings.sttLang.ja')} + {t('settings.sttLang.zh')} 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) }) })