d3ro-voice/docs/phases/phase-14-meeting-mode.md
2026-04-08 00:14:12 +09:00

36 KiB

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 테이블

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 테이블

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)

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)

// ============================================================
// 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)

// === Meeting Mode (880-889) ===
MeetingAlreadyRecording   = 880,
MeetingNotRecording       = 881,
MeetingProcessingFailed   = 882,
MeetingSessionNotFound    = 883,
MeetingExportFailed       = 884,
MeetingPdfFailed          = 885,

5. IPC 채널 (src/shared/ipc-channels.ts)

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)

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 프롬프트

const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다.
전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다.

규칙:
- 전사록/메모에 없는 내용을 추가하지 마세요
- 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요
- 시간 순서를 유지하세요
- 한국어로 작성하세요 (원문이 영어면 원어 유지)

출력 형식:
## 요약
(3-5줄 핵심 요약)

## 핵심 결정사항
- (결정 1)
- (결정 2)

## 할 일 목록
- [ ] (담당자가 있으면 포함) (할 일)

## 타임라인
| 시간 | 내용 |
|------|------|
| MM:SS | 주요 발언/이벤트 |`

사용자 프롬프트:

## 전사록
{rawTranscript}

## 참석자 메모 (📝 표시)
{formattedMemos}

위 전사록과 메모를 바탕으로 회의록을 작성해주세요.

6.5 PDF 내보내기

Electron 내장 BrowserWindow.webContents.printToPDF() 사용. 추가 라이브러리 불필요.

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 템플릿:

<!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 마크다운 내보내기

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
}

내보내기 마크다운 형식:

# 회의록 — {title}

- **일시**: {startedAt} ~ {endedAt}
- **소요 시간**: {duration}
- **STT 모델**: {sttModel}
- **LLM 모델**: {llmModel}

---

{minutesMarkdown}

---

## 참석자 메모

| 시간 | 메모 |
|------|------|
| 03:15 | 예산 확정 필요 |
| 07:42 | 다음 주 수요일까지 리뷰 |

---

## 원문 전사

{rawTranscript}

7. Preload API (src/preload/index.ts)

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)

// 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개 뷰 전환:

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

"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

"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.tsMEETING_MODE 블록 추가
  4. src/main/db/schema.tsmeetingSessions, meetingMemos 스키마
  5. src/main/db/index.tsCREATE TABLE IF NOT EXISTS SQL 추가

Step B: 서비스 계층

  1. src/main/services/MeetingModeService.ts — 핵심 서비스 구현
    • 상태 머신 (idle/recording/processing)
    • startRecording (CaptionService 연동)
    • addMemo (타임스탬프 자동 계산)
    • stopRecording + 후처리 파이프라인
    • getSessions / getSession / deleteSession
    • exportPdf / exportMarkdown
  2. CaptionService 수정 — 회의 모드 활성 시 오버레이 숨김 + session-saved 이벤트 조건부 발생

Step C: IPC + Preload

  1. src/main/ipc/meeting-mode-handlers.ts — IPC 핸들러
  2. src/main/ipc/index.tsregisterMeetingModeHandlers() 호출
  3. src/preload/index.tsmeetingMode 네임스페이스
  4. src/main/bootstrap.ts — MeetingModeService 초기화 단계

Step D: UI

  1. i18n 키 추가 (ko.json, en.json)
  2. src/renderer/pages/MeetingModePage.tsx — 메인 페이지
  3. 서브 컴포넌트:
    • 녹음 뷰 (실시간 자막 + 메모 입력)
    • 상세 뷰 (회의록 렌더링 + 내보내기)
    • 세션 카드 (목록)
  4. src/renderer/components/AppLayout.tsx — Route + NavItem 추가

Step E: 마무리

  1. 라이센스 게이팅 (Feature.MEETING_MODE)
  2. CaptionService 동시 사용 충돌 방지
  3. 긴 회의 대응 (LLM 컨텍스트 윈도우 초과 시 청크 요약)

11. CaptionService 동시 사용 가드

회의 모드가 CaptionService를 사용하므로, 일반 자막 모드와 동시 사용 불가:

// 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 요약:

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