940 lines
36 KiB
Markdown
940 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 모델별 컨텍스트 윈도우:
|
|
- qwen3:4b → 32K tokens (~24K 한국어 자)
|
|
- llama3.2:3b → 128K tokens
|
|
|
|
전사 텍스트가 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` | 회의 모드 연동 (오버레이 숨김) |
|