Phase 14 구현: 회의 모드 (Meeting Mode)
실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 회의록 + PDF/MD 내보내기. CaptionService 연동, 동시 사용 충돌 방지, 긴 회의 2-pass 요약 지원. DB 2테이블, IPC 15채널, UI 3뷰(목록/녹음/상세), 12개 locale i18n.
This commit is contained in:
parent
dd68fc9ccd
commit
d0c4f9ee53
38 changed files with 2550 additions and 769 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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초
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. 원인 분석 후 수정 제안
|
||||
|
|
|
|||
|
|
@ -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 절대 금지
|
||||
|
|
|
|||
|
|
@ -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` 실행을 제안
|
||||
|
|
|
|||
|
|
@ -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 사용
|
||||
|
|
|
|||
|
|
@ -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` 갱신 필요 여부 유저에게 확인
|
||||
|
|
|
|||
366
CLAUDE.md
366
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종)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export async function bootstrap(): Promise<void> {
|
|||
{ 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<void> {
|
|||
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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function initMeetingMode(): Promise<void> {
|
||||
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
|
||||
const { getMeetingModeService } = await import('./services/MeetingModeService')
|
||||
getMeetingModeService()
|
||||
}
|
||||
|
||||
// ── HistoryPopup IPC 연동 ────────────────────────────
|
||||
|
||||
function setupHistoryPopupIPC(): void {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,40 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
|
|||
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 }>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
115
src/main/ipc/meeting-mode-handlers.ts
Normal file
115
src/main/ipc/meeting-mode-handlers.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
750
src/main/services/MeetingModeService.ts
Normal file
750
src/main/services/MeetingModeService.ts
Normal file
|
|
@ -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<MeetingStartResult> {
|
||||
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<MeetingMemo> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number>`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<number>`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<string> {
|
||||
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<string> {
|
||||
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)
|
||||
: '<p>회의록이 생성되지 않았습니다.</p>'
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
|
||||
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
||||
th { background: #f5f5f5; font-weight: 600; }
|
||||
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
||||
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${session.title ?? '무제 회의'}</h1>
|
||||
<p class="meta">일시: ${this._formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
|
||||
${minutesHtml}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
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, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/^(?!<[h|l|t|p])/gm, '')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
let instance: MeetingModeService | null = null
|
||||
|
||||
export function getMeetingModeService(): MeetingModeService {
|
||||
if (!instance) {
|
||||
instance = new MeetingModeService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MeetingStartResult>(IPC_CHANNELS.MEETING_MODE.START_RECORDING),
|
||||
stopRecording: () =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.STOP_RECORDING),
|
||||
addMemo: (params: MeetingAddMemoParams) =>
|
||||
invoke<MeetingMemo>(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params),
|
||||
getState: () =>
|
||||
invoke<MeetingModeStateInfo>(IPC_CHANNELS.MEETING_MODE.GET_STATE),
|
||||
getSessions: (params: MeetingGetSessionsParams) =>
|
||||
invoke<MeetingSessionPage>(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params),
|
||||
getSession: (params: MeetingGetSessionParams) =>
|
||||
invoke<MeetingSessionDetail>(IPC_CHANNELS.MEETING_MODE.GET_SESSION, params),
|
||||
deleteSession: (params: MeetingDeleteSessionParams) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_SESSION, params),
|
||||
updateTitle: (params: { sessionId: string; title: string }) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.UPDATE_TITLE, params),
|
||||
exportPdf: (params: MeetingExportParams) =>
|
||||
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_PDF, params),
|
||||
exportMarkdown: (params: MeetingExportParams) =>
|
||||
invoke<string>(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)
|
||||
|
|
|
|||
|
|
@ -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: <ExtensionIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
|
||||
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
|
||||
]
|
||||
|
||||
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
|
||||
|
|
@ -225,6 +228,7 @@ export function AppLayout(): React.ReactElement {
|
|||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
||||
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
||||
{currentRoute === 'meeting' && <MeetingModePage />}
|
||||
</Box>
|
||||
</Box>
|
||||
<StatusBar />
|
||||
|
|
|
|||
|
|
@ -455,10 +455,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<MenuItem value="auto">{t('settings.theme.system')}</MenuItem>
|
||||
<MenuItem value="light">{t('settings.theme.light')}</MenuItem>
|
||||
<MenuItem value="dark">{t('settings.theme.dark')}</MenuItem>
|
||||
<MenuItem value="nord">Nord</MenuItem>
|
||||
<MenuItem value="solarized">Solarized</MenuItem>
|
||||
<MenuItem value="catppuccin">Catppuccin</MenuItem>
|
||||
<MenuItem value="dracula">Dracula</MenuItem>
|
||||
<MenuItem value="nord">{t('settings.theme.nord')}</MenuItem>
|
||||
<MenuItem value="solarized">{t('settings.theme.solarized')}</MenuItem>
|
||||
<MenuItem value="catppuccin">{t('settings.theme.catppuccin')}</MenuItem>
|
||||
<MenuItem value="dracula">{t('settings.theme.dracula')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
|
|
@ -627,10 +627,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="zh">中文</MenuItem>
|
||||
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
|
||||
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
|
||||
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
|
||||
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ export function TemplateSection(): React.ReactElement {
|
|||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
helperText="Use {{fieldName}} for placeholders"
|
||||
helperText={t('template.outputHelperText')}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "通知を送信中..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "완료 알림 중..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Отправка уведомления..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "กำลังส่งการแจ้งเตือน..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "正在發送通知..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "正在发送通知..."
|
||||
}
|
||||
|
|
|
|||
570
src/renderer/pages/MeetingModePage.tsx
Normal file
570
src/renderer/pages/MeetingModePage.tsx
Normal file
|
|
@ -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<MeetingView>('list')
|
||||
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
|
||||
const [totalSessions, setTotalSessions] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 녹음 뷰 상태
|
||||
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
|
||||
const [segments, setSegments] = useState<CaptionSegment[]>([])
|
||||
const [memos, setMemos] = useState<MeetingMemo[]>([])
|
||||
const [memoInput, setMemoInput] = useState('')
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
|
||||
const transcriptRef = useRef<HTMLDivElement>(null)
|
||||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const recordingStartRef = useRef<number>(0)
|
||||
|
||||
// 상세 뷰 상태
|
||||
const [detail, setDetail] = useState<MeetingSessionDetail | null>(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 (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<PageHeader
|
||||
title={t('meeting.title')}
|
||||
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
|
||||
action={
|
||||
<PhysicalButton size="small" onClick={handleStartRecording}>
|
||||
<AddIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.newMeeting')}
|
||||
</PhysicalButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LinearProgress sx={{ mt: 2 }} />
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{sessions.map((session) => (
|
||||
<MetalCard
|
||||
key={session.id}
|
||||
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.85 } }}
|
||||
onClick={() => handleViewDetail(session.id)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">
|
||||
{session.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
|
||||
{session.durationMs != null
|
||||
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
|
||||
: '—'}
|
||||
{' · '}
|
||||
{t('meeting.memos')}: {session.memoCount}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PhosphorText variant="dim">{formatDate(session.startedAt)}</PhosphorText>
|
||||
<Led color={statusColor(session.status)} size={8} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 프로세싱 중인 세션 표시 */}
|
||||
{session.status === 'processing' && progress && progress.sessionId === session.id && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percent}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: 11 }}>
|
||||
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 렌더: 녹음 뷰 ──
|
||||
if (view === 'recording') {
|
||||
const isProcessing = stateInfo?.state === 'processing'
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 상단 바 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FiberManualRecordIcon sx={{ color: d3roPalette.indicator.red, fontSize: 16, animation: 'pulse 1s infinite' }} />
|
||||
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
|
||||
{formatTime(elapsedMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{!isProcessing && (
|
||||
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
|
||||
<StopIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.stopRecording')}
|
||||
</PhysicalButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 프로세싱 진행률 */}
|
||||
{isProcessing && progress && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
|
||||
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 메인 패널: 전사 + 메모 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
|
||||
{/* 좌: 실시간 전사 */}
|
||||
<ScreenPanel sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
|
||||
{t('meeting.transcript')}
|
||||
</PhosphorText>
|
||||
<Box
|
||||
ref={transcriptRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.reading.fontSize,
|
||||
color: d3roPalette.text.primary,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{segments.map((seg) => (
|
||||
<Box key={seg.id} sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
|
||||
>
|
||||
[{formatTime(seg.timestamp)}]
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
|
||||
{seg.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
||||
{/* 우: 메모 입력 */}
|
||||
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
|
||||
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
|
||||
{t('meeting.memos')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
|
||||
{memos.map((memo) => (
|
||||
<Box
|
||||
key={memo.id}
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
|
||||
{formatTime(memo.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading" sx={{ fontSize: 12 }}>
|
||||
{memo.content}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{!isProcessing && (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={t('meeting.memoPlaceholder')}
|
||||
value={memoInput}
|
||||
onChange={(e) => setMemoInput(e.target.value)}
|
||||
onKeyDown={handleMemoKeyDown}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 렌더: 상세 뷰 ──
|
||||
if (view === 'detail' && detail) {
|
||||
const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<IconButton size="small" onClick={() => { setDetail(null); setView('list') }}>
|
||||
<ArrowBackIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{editingTitle ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
|
||||
sx={{ flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<CheckIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>
|
||||
{detail.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<Tooltip title={t('meeting.editTitle')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 메타 */}
|
||||
<PhosphorText variant="dim" sx={{ mb: 3 }}>
|
||||
{formatDate(detail.startedAt)}
|
||||
{detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
|
||||
{' · '}
|
||||
{t('meeting.duration', { minutes: durationMin })}
|
||||
</PhosphorText>
|
||||
|
||||
{/* 회의록 본문 */}
|
||||
{detail.minutes && (
|
||||
<>
|
||||
{/* 요약 */}
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.summary')}</PhosphorText>
|
||||
<PhosphorText variant="reading" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{detail.minutes.summary}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
|
||||
{/* 결정사항 */}
|
||||
{detail.minutes.decisions.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.decisions')}</PhosphorText>
|
||||
{detail.minutes.decisions.map((d, i) => (
|
||||
<PhosphorText key={i} variant="reading" sx={{ mb: 0.5 }}>
|
||||
{'• '}{d}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 할 일 */}
|
||||
{detail.minutes.actionItems.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.actionItems')}</PhosphorText>
|
||||
{detail.minutes.actionItems.map((item, i) => (
|
||||
<PhosphorText key={i} variant="reading" sx={{ mb: 0.5 }}>
|
||||
{'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
|
||||
{item.deadline ? ` (${item.deadline})` : ''}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 타임라인 */}
|
||||
{detail.minutes.timeline.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.timeline')}</PhosphorText>
|
||||
{detail.minutes.timeline.map((tl, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{tl.time}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading" sx={{ flex: 1 }}>
|
||||
{tl.type === 'memo' ? '📝 ' : ''}{tl.content}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{detail.status === 'error' && detail.errorMessage && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.indicator.red, mb: 1 }}>
|
||||
{t('meeting.error')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading">{detail.errorMessage}</PhosphorText>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 메모 */}
|
||||
{detail.memos.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.memos')}</PhosphorText>
|
||||
{detail.memos.map((m) => (
|
||||
<Box key={m.id} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{formatTime(m.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading">{m.content}</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
|
||||
<Tooltip title={t('meeting.exportPdf')}>
|
||||
<PhysicalButton size="small" onClick={handleExportPdf}>
|
||||
<PictureAsPdfIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportPdf')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.exportMarkdown')}>
|
||||
<PhysicalButton size="small" onClick={handleExportMarkdown}>
|
||||
<DescriptionIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportMarkdown')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.delete')}>
|
||||
<PhysicalButton size="small" color="error" onClick={handleDelete}>
|
||||
<DeleteIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.delete')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return <Box />
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue