diff --git a/docs/phases/phase-14-meeting-mode.md b/docs/phases/phase-14-meeting-mode.md new file mode 100644 index 0000000..d8e84b6 --- /dev/null +++ b/docs/phases/phase-14-meeting-mode.md @@ -0,0 +1,940 @@ +# Phase 14: 회의 모드 (Meeting Mode) + +> 실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 + PDF/MD 내보내기 +> 기존 CaptionService(실시간 자막)를 조합하여, 전용 회의록 파이프라인 구축 + +--- + +## 1. 개요 + +### 1.1 사용자 플로우 + +``` +[사이드바 MTG 탭 클릭] → 회의 모드 페이지 (세션 목록) + │ + ├── [새 회의] 클릭 + │ │ + │ ▼ + │ 녹음 시작 → 마이크 캡처 + 실시간 전사 + │ │ + │ ├── 실시간 자막 표시 (좌측 패널) + │ ├── 메모 입력 (우측 패널, Enter=타임스탬프 자동) + │ ├── 경과 시간 타이머 + │ │ + │ ▼ + │ [녹음 종료] 클릭 + │ │ + │ ▼ + │ 후처리 파이프라인 (자동) + │ ├── 1. 세그먼트 합산 → rawTranscript + │ ├── 2. 메모 + 타임스탬프 병합 + │ ├── 3. LLM → 구조화된 회의록 (요약/결정/할일/타임라인) + │ ├── 4. DB 저장 + │ └── 5. Windows 알림: "회의록이 준비되었습니다" + │ + └── [세션 카드 클릭] → 상세 뷰 + ├── 구조화 회의록 렌더링 + ├── 원문 전사 + 메모 타임라인 + ├── [PDF 내보내기] 버튼 + ├── [마크다운 내보내기] 버튼 + └── [삭제] 버튼 +``` + +### 1.2 핵심 차별점 +- **타임스탬프 메모**: 녹음 중 Enter 누를 때마다 상대 시간(MM:SS) 자동 기록 +- **전사+메모 병합**: LLM이 전사록과 메모를 교차 참조하여 정확한 회의록 생성 +- **완전 로컬**: Ollama + Whisper, 클라우드 의존 없음 + +--- + +## 2. DB 스키마 + +### 2.1 `meeting_sessions` 테이블 + +```sql +CREATE TABLE IF NOT EXISTS meeting_sessions ( + id TEXT PRIMARY KEY, -- nanoid + title TEXT, -- 사용자 지정 또는 LLM 자동 생성 + status TEXT NOT NULL DEFAULT 'recording', -- recording|processing|completed|error + started_at INTEGER NOT NULL, -- epoch ms + ended_at INTEGER, -- epoch ms + duration_ms INTEGER, -- 녹음 길이 + raw_transcript TEXT, -- CaptionService segments 합산 전문 + minutes_markdown TEXT, -- LLM 생성 구조화 회의록 (마크다운) + minutes_json TEXT, -- 파싱된 구조 데이터 (JSON 직렬화) + stt_model TEXT, -- 사용된 Whisper 모델명 + llm_model TEXT, -- 사용된 Ollama 모델명 + stt_latency_ms INTEGER, -- STT 처리 시간 + llm_latency_ms INTEGER, -- LLM 처리 시간 + 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); +``` + +### 2.2 `meeting_memos` 테이블 + +```sql +CREATE TABLE IF NOT EXISTS meeting_memos ( + id TEXT PRIMARY KEY, -- nanoid + session_id TEXT NOT NULL, -- FK → meeting_sessions.id + content TEXT NOT NULL, -- 메모 텍스트 + timestamp_ms INTEGER NOT NULL, -- 녹음 시작 기준 상대 시간 (ms) + 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); +``` + +### 2.3 drizzle-orm 스키마 (`src/main/db/schema.ts`) + +```typescript +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), + ] +) + +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 +``` + +--- + +## 3. 공유 타입 (`src/shared/types.ts`) + +```typescript +// ============================================================ +// Meeting Mode (Phase 14) +// ============================================================ + +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 +} + +// IPC 파라미터 + +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' // LLM 회의록 생성 + | 'parsing' // 마크다운 파싱 + | 'saving' // DB 저장 + | 'notifying' // Windows 알림 + +export interface MeetingProcessingProgress { + sessionId: string + step: MeetingProcessingStep + percent: number +} +``` + +--- + +## 4. 에러 코드 (`src/shared/errors.ts`) + +```typescript +// === Meeting Mode (880-889) === +MeetingAlreadyRecording = 880, +MeetingNotRecording = 881, +MeetingProcessingFailed = 882, +MeetingSessionNotFound = 883, +MeetingExportFailed = 884, +MeetingPdfFailed = 885, +``` + +--- + +## 5. IPC 채널 (`src/shared/ipc-channels.ts`) + +```typescript +MEETING_MODE: { + // handle (양방향) + 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', + // send (Main → Renderer) + STATE_CHANGED: 'meetingMode:stateChanged', + SEGMENT: 'meetingMode:segment', + PROCESSING_PROGRESS: 'meetingMode:processingProgress', + SESSION_COMPLETED: 'meetingMode:sessionCompleted', + ERROR: 'meetingMode:error', +}, +``` + +| 채널 | 방향 | 파라미터 | 반환 | 설명 | +|------|------|---------|------|------| +| `meetingMode:startRecording` | handle | `void` | `MeetingStartResult` | 녹음+전사 시작 | +| `meetingMode:stopRecording` | handle | `void` | `void` | 녹음 종료 → 후처리 파이프라인 트리거 | +| `meetingMode:addMemo` | handle | `MeetingAddMemoParams` | `MeetingMemo` | 메모 추가 (타임스탬프 자동) | +| `meetingMode:getState` | handle | `void` | `MeetingModeStateInfo` | 현재 상태 조회 | +| `meetingMode:getSessions` | handle | `MeetingGetSessionsParams` | `MeetingSessionPage` | 세션 목록 (페이지네이션) | +| `meetingMode:getSession` | handle | `MeetingGetSessionParams` | `MeetingSessionDetail` | 세션 상세 (메모 포함) | +| `meetingMode:deleteSession` | handle | `MeetingDeleteSessionParams` | `void` | 세션 삭제 | +| `meetingMode:updateTitle` | handle | `{ sessionId: string; title: string }` | `void` | 제목 수정 | +| `meetingMode:exportPdf` | handle | `MeetingExportParams` | `string` | PDF 파일 경로 | +| `meetingMode:exportMarkdown` | handle | `MeetingExportParams` | `string` | MD 파일 경로 | +| `meetingMode:stateChanged` | send | — | `MeetingModeStateInfo` | 상태 전이 알림 | +| `meetingMode:segment` | send | — | `CaptionSegment` | 실시간 전사 세그먼트 포워드 | +| `meetingMode:processingProgress` | send | — | `MeetingProcessingProgress` | 후처리 진행률 | +| `meetingMode:sessionCompleted` | send | — | `MeetingSessionDetail` | 후처리 완료 | +| `meetingMode:error` | send | — | `{ code: number; message: string }` | 에러 알림 | + +--- + +## 6. MeetingModeService 서비스 명세 + +**파일**: `src/main/services/MeetingModeService.ts` +**패턴**: 싱글톤 + EventEmitter + +### 6.1 상태 머신 + +``` +IDLE ──startRecording()──► RECORDING ──stopRecording()──► PROCESSING ──success──► IDLE + │ │ + │ error │ error + ▼ ▼ + IDLE (취소) IDLE (에러 세션 저장) +``` + +### 6.2 CaptionService 연동 전략 + +``` +startRecording(): + 1. CaptionService.getState() 체크 — 이미 active이면 에러 + 2. DB에 meeting_sessions INSERT (status='recording') + 3. CaptionService.start() 호출 + 4. CaptionService.on('segment', handler) 구독 + → segments 배열에 축적 + 렌더러에 meetingMode:segment 포워드 + 5. CaptionService의 오버레이는 숨김 (hideCaptionOverlay) + → 회의 모드 UI가 자체 자막 패널에 표시 + +stopRecording(): + 1. CaptionService.stop() 호출 + → session-saved 이벤트 발생하지만, _meetingModeActive 플래그로 + MeetingSummaryService.onCaptionSessionSaved() 호출 방지 + 2. 수집된 segments를 rawTranscript로 합산 + 3. _runPostProcessing() 시작 +``` + +### 6.3 후처리 파이프라인 (`_runPostProcessing`) + +```typescript +private async _runPostProcessing(): Promise { + this._setState('processing') + const sessionId = this._sessionId! + const db = getDatabase() + + try { + // Step 1: 전사 텍스트 합산 (20%) + this._sendProgress(sessionId, 'merging', 20) + const rawTranscript = this._segments + .map(s => `[${this._formatTime(s.startMs)}] ${s.text}`) + .join('\n') + + // Step 2: 메모 포맷팅 + const formattedMemos = this._memos + .map(m => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`) + .join('\n') + + // Step 3: LLM 구조화 (40~80%) + this._sendProgress(sessionId, 'generating', 40) + const llmService = getLocalLLMService() + const prompt = this._buildPrompt(rawTranscript, formattedMemos) + const sttEnd = Date.now() + const result = await llmService.generate(prompt, { + systemPrompt: MEETING_MODE_SYSTEM_PROMPT, + temperature: 0.3, + maxTokens: 4096, + }) + const llmEnd = Date.now() + + // Step 4: 마크다운 파싱 (85%) + this._sendProgress(sessionId, 'parsing', 85) + const minutes = this._parseMinutes(result.text) + + // Step 5: DB 저장 (90%) + this._sendProgress(sessionId, 'saving', 90) + const now = Date.now() + db.update(meetingSessions).set({ + status: 'completed', + rawTranscript, + minutesMarkdown: result.text, + minutesJson: JSON.stringify(minutes), + sttModel: configGet('sttModelId'), + llmModel: result.model, + sttLatencyMs: sttEnd - this._sessionStartedAt!, + llmLatencyMs: llmEnd - sttEnd, + updatedAt: now, + }).where(eq(meetingSessions.id, sessionId)).run() + + // LLM이 제목 자동 생성 (첫 10자 기반 또는 요약 첫 줄) + if (!this._sessionTitle) { + 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 알림 (95%) + this._sendProgress(sessionId, 'notifying', 95) + new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show() + + // 완료 이벤트 + this._sendProgress(sessionId, 'notifying', 100) + const detail = this._loadSessionDetail(sessionId) + if (detail) { + mainWindow?.webContents.send(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail) + } + + this._setState('idle') + } 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._setState('idle') + // 에러 알림 + } +} +``` + +### 6.4 LLM 프롬프트 + +```typescript +const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다. +전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다. + +규칙: +- 전사록/메모에 없는 내용을 추가하지 마세요 +- 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요 +- 시간 순서를 유지하세요 +- 한국어로 작성하세요 (원문이 영어면 원어 유지) + +출력 형식: +## 요약 +(3-5줄 핵심 요약) + +## 핵심 결정사항 +- (결정 1) +- (결정 2) + +## 할 일 목록 +- [ ] (담당자가 있으면 포함) (할 일) + +## 타임라인 +| 시간 | 내용 | +|------|------| +| MM:SS | 주요 발언/이벤트 |` +``` + +사용자 프롬프트: +``` +## 전사록 +{rawTranscript} + +## 참석자 메모 (📝 표시) +{formattedMemos} + +위 전사록과 메모를 바탕으로 회의록을 작성해주세요. +``` + +### 6.5 PDF 내보내기 + +Electron 내장 `BrowserWindow.webContents.printToPDF()` 사용. 추가 라이브러리 불필요. + +```typescript +async exportPdf(sessionId: string): Promise { + const session = this._loadSessionDetail(sessionId) + if (!session) throw new D3ROError(ErrorCode.MeetingSessionNotFound, ...) + + const html = buildMeetingPdfHtml(session) + + // 저장 경로 선택 + const { filePath } = await dialog.showSaveDialog({ + title: 'PDF로 내보내기', + defaultPath: `회의록_${formatDateFile(session.startedAt)}.pdf`, + filters: [{ name: 'PDF', extensions: ['pdf'] }], + }) + if (!filePath) return '' + + // 숨겨진 윈도우에서 PDF 생성 + const win = new BrowserWindow({ show: false, width: 800, height: 600 }) + 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 }, + }) + win.close() + + fs.writeFileSync(filePath, pdfBuffer) + return filePath +} +``` + +HTML 템플릿: +```html + + + + + + + +

📋 {title}

+

일시: {dateRange} | 소요: {duration}

+ {minutesHtml} + + +``` + +### 6.6 마크다운 내보내기 + +```typescript +async exportMarkdown(sessionId: string): Promise { + const session = this._loadSessionDetail(sessionId) + if (!session) throw ... + + const { filePath } = await dialog.showSaveDialog({ + title: '마크다운으로 내보내기', + defaultPath: `회의록_${formatDateFile(session.startedAt)}.md`, + filters: [{ name: 'Markdown', extensions: ['md'] }], + }) + if (!filePath) return '' + + const md = this._buildExportMarkdown(session) + fs.writeFileSync(filePath, md, 'utf-8') + return filePath +} +``` + +내보내기 마크다운 형식: +```markdown +# 회의록 — {title} + +- **일시**: {startedAt} ~ {endedAt} +- **소요 시간**: {duration} +- **STT 모델**: {sttModel} +- **LLM 모델**: {llmModel} + +--- + +{minutesMarkdown} + +--- + +## 참석자 메모 + +| 시간 | 메모 | +|------|------| +| 03:15 | 예산 확정 필요 | +| 07:42 | 다음 주 수요일까지 리뷰 | + +--- + +## 원문 전사 + +{rawTranscript} +``` + +--- + +## 7. Preload API (`src/preload/index.ts`) + +```typescript +meetingMode: { + startRecording: () => + invoke(IPC_CHANNELS.MEETING_MODE.START_RECORDING), + stopRecording: () => + invoke(IPC_CHANNELS.MEETING_MODE.STOP_RECORDING), + addMemo: (params: MeetingAddMemoParams) => + invoke(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params), + getState: () => + invoke(IPC_CHANNELS.MEETING_MODE.GET_STATE), + getSessions: (params: MeetingGetSessionsParams) => + invoke(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params), + getSession: (params: MeetingGetSessionParams) => + invoke(IPC_CHANNELS.MEETING_MODE.GET_SESSION, params), + deleteSession: (params: MeetingDeleteSessionParams) => + invoke(IPC_CHANNELS.MEETING_MODE.DELETE_SESSION, params), + updateTitle: (params: { sessionId: string; title: string }) => + invoke(IPC_CHANNELS.MEETING_MODE.UPDATE_TITLE, params), + exportPdf: (params: MeetingExportParams) => + invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_PDF, params), + exportMarkdown: (params: MeetingExportParams) => + invoke(IPC_CHANNELS.MEETING_MODE.EXPORT_MARKDOWN, params), + + onStateChanged: (cb: (e: MeetingModeStateInfo) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, cb), + onSegment: (cb: (e: CaptionSegment) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.SEGMENT, cb), + onProcessingProgress: (cb: (e: MeetingProcessingProgress) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, cb), + onSessionCompleted: (cb: (e: MeetingSessionDetail) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, cb), + onError: (cb: (e: { code: number; message: string }) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_MODE.ERROR, cb), +}, +``` + +--- + +## 8. UI 컴포넌트 + +### 8.1 라우팅 (`AppLayout.tsx`) + +```typescript +// Route 타입에 추가 +type Route = '...' | 'meeting' + +// NAV_ITEMS에 추가 (conversation과 knowledge 사이) +{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: } + +// 렌더링 분기에 추가 +{currentRoute === 'meeting' && } +``` + +### 8.2 MeetingModePage (`src/renderer/pages/MeetingModePage.tsx`) + +내부 상태로 3개 뷰 전환: + +```typescript +type MeetingView = 'list' | 'recording' | 'detail' + +const [view, setView] = useState('list') +const [activeSessionId, setActiveSessionId] = useState(null) +``` + +### 8.3 목록 뷰 + +``` +┌─────────────────────────────────────────────────────┐ +│ PageHeader: "회의 모드" [+ 새 회의] 버튼 │ +├─────────────────────────────────────────────────────┤ +│ ┌─ MetalCard ─────────────────────────────────┐ │ +│ │ ● 프로젝트 킥오프 2026-04-06 14:30 │ │ +│ │ 42분 · 메모 5개 [completed] Led │ │ +│ └──────────────────────────────────────────────┘ │ +│ ┌─ MetalCard ─────────────────────────────────┐ │ +│ │ ● 디자인 리뷰 2026-04-05 10:00 │ │ +│ │ 28분 · 메모 3개 [completed] Led │ │ +│ └──────────────────────────────────────────────┘ │ +│ (빈 상태: EmptyStateCard "녹음을 시작하세요") │ +└─────────────────────────────────────────────────────┘ +``` + +### 8.4 녹음 뷰 + +``` +┌──────────────────────────────────────────────────────────┐ +│ ⏱ 05:23 ●REC [■ 녹음 종료] 버튼 │ +├───────────────────────────┬──────────────────────────────┤ +│ 실시간 전사 (ScreenPanel) │ 메모 입력 패널 │ +│ │ │ +│ [00:15] 안녕하세요 │ ┌─ 입력된 메모 목록 ─────┐ │ +│ 오늘 킥오프 미팅을 │ │ 03:15 예산 확정 필요 │ │ +│ 시작하겠습니다 │ │ 05:02 다음 주 리뷰 │ │ +│ │ └────────────────────────┘ │ +│ [01:30] 첫 번째 안건은 │ │ +│ 예산 관련인데요... │ ┌─────────────────────────┐ │ +│ │ │ 메모 입력... [Enter] │ │ +│ (자동 스크롤 ↓) │ └─────────────────────────┘ │ +├───────────────────────────┴──────────────────────────────┤ +│ ░░░░░░░░░░░░░░░ 오디오 레벨 미터 │ +└──────────────────────────────────────────────────────────┘ +``` + +- 좌측: `ScreenPanel`에 CaptionSegment 실시간 표시 (auto-scroll) +- 우측: 메모 리스트 + 입력 필드 (`TextField`, Enter 키 핸들러) +- 메모 입력 시: `window.electronAPI.meetingMode.addMemo({ content })` 호출 +- Enter 키: 메모 추가, 타임스탬프 자동 (서비스가 `sessionStartedAt` 기준 계산) +- 상단 타이머: `setInterval(1000)` → `Date.now() - recordingStartedAt` + +### 8.5 상세 뷰 + +``` +┌──────────────────────────────────────────────────────────┐ +│ [← 목록] 제목 (편집 가능) │ +│ 2026-04-06 14:30 ~ 15:12 · 42분 │ +├──────────────────────────────────────────────────────────┤ +│ ┌─ 요약 (ScreenPanel) ─────────────────────────────┐ │ +│ │ 프로젝트 예산 확정 및 일정 조율을 위한 킥오프 미팅. │ │ +│ │ Q2 예산 5000만원 확정, 디자인 리뷰 4/10까지... │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 결정사항 ────────────────────────────────────────┐ │ +│ │ • Q2 예산 5000만원 확정 │ │ +│ │ • 디자인 시안 A 채택 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 할 일 ──────────────────────────────────────────┐ │ +│ │ □ 김팀장: 예산안 최종 제출 (4/8까지) │ │ +│ │ □ 박대리: 디자인 시안 수정본 공유 (4/10까지) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 타임라인 ────────────────────────────────────────┐ │ +│ │ 00:15 인사 및 안건 소개 │ │ +│ │ 03:15 📝 예산 확정 필요 │ │ +│ │ 05:30 예산 토론 │ │ +│ │ 12:00 디자인 리뷰 시작 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ [📄 PDF] [📝 Markdown] [🗑 삭제] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 8.6 프로세싱 오버레이 + +녹음 종료 후 후처리 중 목록 뷰에 진행 상태 표시: + +``` +┌─ MetalCard (processing) ──────────────────┐ +│ ● 회의록 생성 중... ⏳ │ +│ ████████████░░░░░░░░ 60% — LLM 처리 중 │ +└───────────────────────────────────────────┘ +``` + +--- + +## 9. i18n 키 + +### ko.json + +```json +"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": "완료 알림 중..." +``` + +### en.json + +```json +"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..." +``` + +--- + +## 10. 구현 순서 + +### Step A: 기반 인프라 (타입 + DB + IPC 채널) +1. `src/shared/types.ts` — Meeting Mode 타입 추가 +2. `src/shared/errors.ts` — 에러 코드 880-885 추가 +3. `src/shared/ipc-channels.ts` — `MEETING_MODE` 블록 추가 +4. `src/main/db/schema.ts` — `meetingSessions`, `meetingMemos` 스키마 +5. `src/main/db/index.ts` — `CREATE TABLE IF NOT EXISTS` SQL 추가 + +### Step B: 서비스 계층 +6. `src/main/services/MeetingModeService.ts` — 핵심 서비스 구현 + - 상태 머신 (idle/recording/processing) + - startRecording (CaptionService 연동) + - addMemo (타임스탬프 자동 계산) + - stopRecording + 후처리 파이프라인 + - getSessions / getSession / deleteSession + - exportPdf / exportMarkdown +7. CaptionService 수정 — 회의 모드 활성 시 오버레이 숨김 + session-saved 이벤트 조건부 발생 + +### Step C: IPC + Preload +8. `src/main/ipc/meeting-mode-handlers.ts` — IPC 핸들러 +9. `src/main/ipc/index.ts` — `registerMeetingModeHandlers()` 호출 +10. `src/preload/index.ts` — `meetingMode` 네임스페이스 +11. `src/main/bootstrap.ts` — MeetingModeService 초기화 단계 + +### Step D: UI +12. i18n 키 추가 (`ko.json`, `en.json`) +13. `src/renderer/pages/MeetingModePage.tsx` — 메인 페이지 +14. 서브 컴포넌트: + - 녹음 뷰 (실시간 자막 + 메모 입력) + - 상세 뷰 (회의록 렌더링 + 내보내기) + - 세션 카드 (목록) +15. `src/renderer/components/AppLayout.tsx` — Route + NavItem 추가 + +### Step E: 마무리 +16. 라이센스 게이팅 (Feature.MEETING_MODE) +17. CaptionService 동시 사용 충돌 방지 +18. 긴 회의 대응 (LLM 컨텍스트 윈도우 초과 시 청크 요약) + +--- + +## 11. CaptionService 동시 사용 가드 + +회의 모드가 CaptionService를 사용하므로, 일반 자막 모드와 동시 사용 불가: + +```typescript +// VoiceModeService._toggleCaption()에 가드 추가 +if (getMeetingModeService().getState() === 'recording') { + logger.warn('Cannot start caption: meeting mode recording active') + return +} + +// MeetingModeService.startRecording()에 가드 추가 +const captionState = getCaptionService().getState() +if (captionState !== 'inactive') { + throw new D3ROError(ErrorCode.MeetingAlreadyRecording, '자막 모드가 활성 상태입니다') +} +``` + +--- + +## 12. 긴 회의 대응 전략 + +Ollama 모델별 컨텍스트 윈도우: +- qwen3:4b → 32K tokens (~24K 한국어 자) +- llama3.2:3b → 128K tokens + +전사 텍스트가 20,000자를 초과하면 2-pass 요약: + +```typescript +private async _runPostProcessing(): Promise { + // ... + const transcript = rawTranscript + + if (transcript.length > 20000) { + // Pass 1: 5000자 청크로 분할 → 각각 요약 + const chunks = this._splitIntoChunks(transcript, 5000) + const chunkSummaries = await Promise.all( + chunks.map(chunk => llmService.generate(chunk, { + systemPrompt: '다음 회의 전사 일부를 3줄로 요약하세요.', + temperature: 0.3, + })) + ) + // Pass 2: 청크 요약 합산 + 메모 → 최종 구조화 + const condensed = chunkSummaries.map(r => r.text).join('\n\n') + // condensed + memos로 최종 프롬프트 생성 + } +} +``` + +--- + +## 파일 목록 (신규/수정) + +### 신규 파일 +| 파일 | 설명 | +|------|------| +| `src/main/services/MeetingModeService.ts` | 핵심 서비스 | +| `src/main/ipc/meeting-mode-handlers.ts` | IPC 핸들러 | +| `src/renderer/pages/MeetingModePage.tsx` | 메인 페이지 | +| `docs/phases/phase-14-meeting-mode.md` | 본 설계서 | + +### 수정 파일 +| 파일 | 변경 내용 | +|------|----------| +| `src/shared/types.ts` | Meeting Mode 타입 추가 | +| `src/shared/errors.ts` | 에러 코드 880-885 추가 | +| `src/shared/ipc-channels.ts` | MEETING_MODE 채널 블록 | +| `src/main/db/schema.ts` | meetingSessions, meetingMemos 테이블 | +| `src/main/db/index.ts` | CREATE TABLE SQL | +| `src/main/ipc/index.ts` | registerMeetingModeHandlers 호출 | +| `src/preload/index.ts` | meetingMode API 네임스페이스 | +| `src/main/bootstrap.ts` | 초기화 단계 추가 | +| `src/renderer/components/AppLayout.tsx` | Route + NavItem | +| `src/renderer/i18n/ko.json` | meeting.* 키 | +| `src/renderer/i18n/en.json` | meeting.* 키 | +| `src/main/services/CaptionService.ts` | 회의 모드 연동 (오버레이 숨김) | diff --git a/src/main/services/HotkeyService.ts b/src/main/services/HotkeyService.ts index 40a7232..eb71a4c 100644 --- a/src/main/services/HotkeyService.ts +++ b/src/main/services/HotkeyService.ts @@ -476,28 +476,32 @@ class HotkeyService extends EventEmitter { // 핫키에 지정된 수정자가 모두 눌려 있어야 하고, // 지정되지 않은 수정자는 눌려 있으면 안 된다. // - // 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt)에는 - // 해당 수정자의 altKey 등이 true로 올 수 있으므로, 주 키가 수정자인 경우 - // 해당 수정자 검사를 건너뛴다. - const isKeyModifier = this._isModifierKeyCode(config.keyCode) + // 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt 단독): + // - 해당 수정자의 altKey 등이 true로 올 수 있으므로, + // modifiers에 해당 modifier가 **없을 때만** 이벤트의 해당 플래그를 무시한다. + // - modifiers에 해당 modifier가 **있으면** (예: Alt+Right Alt은 불가하므로) + // 해당 플래그가 true여야 매칭 성공. + // + // 예: Right Alt 단독 (keyCode=AltRight, modifiers=[]) + // → e.altKey가 true여도 wantsAlt=false이므로 bypass → match + // 예: Ctrl+Right Alt (keyCode=AltRight, modifiers=['ctrl']) + // → e.altKey bypass, e.ctrlKey===true 체크 → match only with Ctrl held const wantsCtrl = config.modifiers.includes('ctrl') const wantsAlt = config.modifiers.includes('alt') const wantsShift = config.modifiers.includes('shift') const wantsMeta = config.modifiers.includes('meta') - const ctrlMatch = isKeyModifier && this._isCtrlKeyCode(config.keyCode) - ? true - : e.ctrlKey === wantsCtrl - const altMatch = isKeyModifier && this._isAltKeyCode(config.keyCode) - ? true - : e.altKey === wantsAlt - const shiftMatch = isKeyModifier && this._isShiftKeyCode(config.keyCode) - ? true - : e.shiftKey === wantsShift - const metaMatch = isKeyModifier && this._isMetaKeyCode(config.keyCode) - ? true - : e.metaKey === wantsMeta + // 주 키 자체가 해당 modifier인 경우, 그 modifier의 이벤트 플래그는 무시 + const skipCtrl = this._isCtrlKeyCode(config.keyCode) && !wantsCtrl + const skipAlt = this._isAltKeyCode(config.keyCode) && !wantsAlt + const skipShift = this._isShiftKeyCode(config.keyCode) && !wantsShift + const skipMeta = this._isMetaKeyCode(config.keyCode) && !wantsMeta + + const ctrlMatch = skipCtrl || (e.ctrlKey === wantsCtrl) + const altMatch = skipAlt || (e.altKey === wantsAlt) + const shiftMatch = skipShift || (e.shiftKey === wantsShift) + const metaMatch = skipMeta || (e.metaKey === wantsMeta) if (ctrlMatch && altMatch && shiftMatch && metaMatch) { return config diff --git a/src/renderer/components/HotkeyRecordModal.tsx b/src/renderer/components/HotkeyRecordModal.tsx index 0223abe..849db25 100644 --- a/src/renderer/components/HotkeyRecordModal.tsx +++ b/src/renderer/components/HotkeyRecordModal.tsx @@ -85,6 +85,10 @@ export function HotkeyRecordModal({ const [captured, setCaptured] = useState | null>(null) const [error, setError] = useState(null) const pressedRef = useRef>(new Map()) + // modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기) + const modifierTimerRef = useRef | null>(null) + // 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지) + const lastPressedRef = useRef>([]) // Modal 열릴 때 리셋 useEffect(() => { @@ -93,6 +97,11 @@ export function HotkeyRecordModal({ setCaptured(null) setError(null) pressedRef.current.clear() + lastPressedRef.current = [] + if (modifierTimerRef.current) { + clearTimeout(modifierTimerRef.current) + modifierTimerRef.current = null + } } }, [open]) @@ -101,6 +110,12 @@ export function HotkeyRecordModal({ e.preventDefault() e.stopPropagation() + // 새 키가 눌렸으므로 modifier-only 타이머 취소 + if (modifierTimerRef.current) { + clearTimeout(modifierTimerRef.current) + modifierTimerRef.current = null + } + const keyCode = e.keyCode || e.which if (pressedRef.current.has(keyCode)) return @@ -109,8 +124,9 @@ export function HotkeyRecordModal({ const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) setPressedKeys(keys) + lastPressedRef.current = keys - // modifier가 아닌 키가 눌리면 → 조합 확정 + // modifier가 아닌 키가 눌리면 → 조합 즉시 확정 (Alt+1, Ctrl+F5 등) if (!isModifier(keyCode)) { setCaptured(keys) } @@ -122,14 +138,20 @@ export function HotkeyRecordModal({ const keyCode = e.keyCode || e.which pressedRef.current.delete(keyCode) - // 모든 키를 놓았고 modifier만 눌렀었다면 → 단일 modifier 확정 - if (pressedRef.current.size === 0 && pressedKeys.length > 0 && pressedKeys.every(k => isModifier(k.keyCode))) { - setCaptured(pressedKeys) + // 모든 키를 놓았고 modifier만 눌렀었다면 → 500ms 대기 후 확정 + // 이 대기 시간 동안 추가 키를 누르면 타이머가 취소되어 조합키로 확장 가능 + if (pressedRef.current.size === 0 && lastPressedRef.current.length > 0 && lastPressedRef.current.every(k => isModifier(k.keyCode))) { + if (modifierTimerRef.current) clearTimeout(modifierTimerRef.current) + modifierTimerRef.current = setTimeout(() => { + // 타이머 만료: 여전히 confirmed 안됐고 추가 키 입력 없으면 단일 modifier로 확정 + setCaptured(lastPressedRef.current) + modifierTimerRef.current = null + }, 500) } const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) setPressedKeys(keys) - }, [captured, pressedKeys]) + }, [captured]) useEffect(() => { if (!open) return @@ -159,15 +181,27 @@ export function HotkeyRecordModal({ } // HotkeyBinding 생성 + // 주 키(main key) = modifier가 아닌 키가 있으면 그것, 없으면 첫 번째 modifier const mainKey = captured.find(k => !isModifier(k.keyCode)) const primaryKeyCode = mainKey?.keyCode ?? captured[0].keyCode + // modifier 플래그: 주 키 자체가 해당 modifier인 경우 false 유지 + // 예: Right Alt 단독 → keyCode=165, alt=false (주 키가 Alt 자체이므로) + // 예: Ctrl+A → keyCode=A, ctrl=true (Ctrl은 modifier로 사용) + const isCtrlKey = (kc: number) => kc === 17 || kc === 162 || kc === 163 + const isAltKey = (kc: number) => kc === 18 || kc === 164 || kc === 165 + const isShiftKey = (kc: number) => kc === 16 || kc === 160 || kc === 161 + const isMetaKey = (kc: number) => kc === 91 || kc === 92 + + // 주 키를 제외한 나머지 키들만 modifier로 취급 + const modifierKeys = captured.filter(k => k.keyCode !== primaryKeyCode) + const binding: HotkeyBinding = { keyCode: primaryKeyCode, - ctrl: captured.some(k => k.keyCode === 17 || k.keyCode === 162 || k.keyCode === 163), - alt: captured.some(k => k.keyCode === 18 || k.keyCode === 164 || k.keyCode === 165), - shift: captured.some(k => k.keyCode === 16 || k.keyCode === 160 || k.keyCode === 161), - meta: captured.some(k => k.keyCode === 91 || k.keyCode === 92), + ctrl: modifierKeys.some(k => isCtrlKey(k.keyCode)), + alt: modifierKeys.some(k => isAltKey(k.keyCode)), + shift: modifierKeys.some(k => isShiftKey(k.keyCode)), + meta: modifierKeys.some(k => isMetaKey(k.keyCode)), displayLabel: label, }