d3ro-voice/docs/phases/phase-14-meeting-mode.md
윤찬 d397bcbf57 feat(desktop): LLM 기본 모델 qwen3:4b → gemma4:e4b 전면 전환 + think:false 안전장치
qwen3:4b가 reasoning 모델이라 <think>...</think> 블록을 길게 생성 →
stripReasoningBlocks 후 빈 문자열 → 원본 transcript fallback으로 끝나면서
LLM refine이 42초 걸리는 병목 발견. Google Gemma 4 e4b(4.5B effective
params, 2026-04-02 릴리스)로 교체. non-reasoning 기본 + Ollama v0.20+
think: false 파라미터로 2중 방어.

실측 결과: 받아쓰기 한 사이클 51.4s → 5.5s (9.3배 빠름).
  STT 500ms + LLM 3,925ms + insert 1,092ms.
refine 품질 정상 동작 확인: "테스트하는 중입니다" → "테스트하고 있습니다".

- LocalLLMService: 3개 fallback 기본값 변경(generate / streamGenerate /
  chatStream) + Ollama 요청 body에 think: false 명시 추가. non-reasoning
  모델은 무시, reasoning 모델은 thinking 토큰 차단. NO_THINK 주석을
  legacy 설명으로 업데이트 — qwen3/deepseek-r1 수동 선택자를 위한 3중
  방어(/no_think + think:false + stripReasoningBlocks) 명시.
- OnboardingModal / OllamaGuideModal: pull 명령어 갱신
- 테스트 fixture 갱신
- 12개 i18n locale JSON: settings.ollamaHint / ollama.step2.alt 키 업데이트
  (qwen3:4b → gemma4:e4b, qwen3:8b → gemma4:26b)
- 10개 site i18n locale TS + HowItWorks.tsx 파이프라인 시각화 — detail
  문자열 'qwen3 / llama3 / gemma3' → 'gemma4 / llama3.2 / phi4',
  파이프라인 라벨 'qwen3:4b @ localhost' → 'gemma4:e4b @ localhost'
- 설계서 00 LLMConfig 기본값 + CONFIG_DEFAULTS
- 설계서 05: 6개 API 스키마 예시, 2개 OllamaClient 코드 예시, LLM 모델
  추천 표 재정렬(gemma4:e4b 최상위, qwen3는 reasoning 경고와 함께 후순위),
  권장 JSON 설정에 think:false 추가
- phase-14 meeting mode 컨텍스트 윈도우 표 갱신
- V2-5 Mac 부트스트랩 가이드 pull 커맨드 갱신
- project_status.md Part 7 전체 섹션 추가
2026-04-12 09:47:08 +09:00

941 lines
36 KiB
Markdown

# 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<void> {
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<string> {
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
<!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; }
.checkbox { color: #666; }
</style>
</head>
<body>
<h1>📋 {title}</h1>
<p class="meta">일시: {dateRange} | 소요: {duration}</p>
{minutesHtml}
</body>
</html>
```
### 6.6 마크다운 내보내기
```typescript
async exportMarkdown(sessionId: string): Promise<string> {
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<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),
},
```
---
## 8. UI 컴포넌트
### 8.1 라우팅 (`AppLayout.tsx`)
```typescript
// Route 타입에 추가
type Route = '...' | 'meeting'
// NAV_ITEMS에 추가 (conversation과 knowledge 사이)
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon /> }
// 렌더링 분기에 추가
{currentRoute === 'meeting' && <MeetingModePage />}
```
### 8.2 MeetingModePage (`src/renderer/pages/MeetingModePage.tsx`)
내부 상태로 3개 뷰 전환:
```typescript
type MeetingView = 'list' | 'recording' | 'detail'
const [view, setView] = useState<MeetingView>('list')
const [activeSessionId, setActiveSessionId] = useState<string | null>(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 모델별 컨텍스트 윈도우:
- gemma4:e4b → 128K tokens (기본 추천)
- llama3.2:3b → 128K tokens
- qwen3:4b → 32K tokens (~24K 한국어 자) ⚠ reasoning 모델
전사 텍스트가 20,000자를 초과하면 2-pass 요약:
```typescript
private async _runPostProcessing(): Promise<void> {
// ...
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` | 회의 모드 연동 (오버레이 숨김) |