# 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` | 회의 모드 연동 (오버레이 숨김) |