Phase 14.5 구현: 회의 모드 고도화 — 다중 문서 생성/편집/내보내기

PLAUD 수준의 기능 확장:
- 전사 편집: 인라인 편집 + 원본 보존 + 수정됨 표시(점선 밑줄)
- 다중 문서 생성: 회의록/보고서/아이디어노트/커스텀 템플릿
- MD 에디터: 렌더링/편집 토글 (react-markdown + remark-gfm)
- 상세 페이지 리디자인: 풀스크린 탭 전환 구조
- 내보내기: MD, PDF, TXT, DOCX 4종 (docx 패키지)
- 커스텀 프롬프트 템플릿 저장/재사용 (electron-store)
- SSOT: 마크다운 파싱 유틸 추출, 후처리에서 자동 문서 생성 제거

DB: meeting_documents 테이블 + edited_transcript 컬럼
IPC: 8+4 채널, 서비스 2개(MeetingModeService 확장 + MeetingDocTemplateService 신규)
UI: 8개 신규 컴포넌트 (meeting/ 디렉토리), 12개 locale i18n
This commit is contained in:
Yun Chan 2026-04-08 09:53:54 +09:00
parent 6197ceb132
commit d4928ffa60
38 changed files with 4791 additions and 438 deletions

View file

@ -299,6 +299,9 @@ async function initMeetingMode(): Promise<void> {
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
const { getMeetingModeService } = await import('./services/MeetingModeService')
getMeetingModeService()
// MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장)
const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService')
getMeetingDocTemplateService()
}
// ── HistoryPopup IPC 연동 ────────────────────────────

View file

@ -163,6 +163,34 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
`)
// Phase 14.5: meeting_documents 테이블 + edited_transcript 컬럼
sqlite.exec(`
CREATE TABLE IF NOT EXISTS meeting_documents (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
template_type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
prompt_used TEXT,
llm_model TEXT,
llm_latency_ms INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
`)
// Phase 14.5: meeting_sessions에 edited_transcript 컬럼 마이그레이션
try {
const msCols = sqlite.pragma('table_info(meeting_sessions)') as Array<{ name: string }>
if (!msCols.some((c) => c.name === 'edited_transcript')) {
sqlite.exec('ALTER TABLE meeting_sessions ADD COLUMN edited_transcript TEXT')
logger.info('Migrated: added edited_transcript column to meeting_sessions')
}
} catch (err) {
logger.warn('edited_transcript migration check failed:', err)
}
// Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션
try {
const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }>

View file

@ -165,6 +165,7 @@ export const meetingSessions = sqliteTable(
endedAt: integer('ended_at'),
durationMs: integer('duration_ms'),
rawTranscript: text('raw_transcript'),
editedTranscript: text('edited_transcript'),
minutesMarkdown: text('minutes_markdown'),
minutesJson: text('minutes_json'),
sttModel: text('stt_model'),
@ -202,6 +203,31 @@ export type NewMeetingSessionRow = typeof meetingSessions.$inferInsert
export type MeetingMemoRow = typeof meetingMemos.$inferSelect
export type NewMeetingMemoRow = typeof meetingMemos.$inferInsert
// ── meetingDocuments (Phase 14.5) ───────────────────────
export const meetingDocuments = sqliteTable(
'meeting_documents',
{
id: text('id').primaryKey(),
sessionId: text('session_id').notNull(),
templateType: text('template_type', {
enum: ['minutes', 'report', 'idea-note', 'custom'],
}).notNull(),
title: text('title').notNull(),
content: text('content').notNull().default(''),
promptUsed: text('prompt_used'),
llmModel: text('llm_model'),
llmLatencyMs: integer('llm_latency_ms'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
index('idx_meeting_documents_session_id').on(table.sessionId),
]
)
export type MeetingDocumentRow = typeof meetingDocuments.$inferSelect
export type NewMeetingDocumentRow = typeof meetingDocuments.$inferInsert
// ── 타입 추출 ────────────────────────────────────────────
export type History = typeof history.$inferSelect
export type NewHistory = typeof history.$inferInsert

View file

@ -24,6 +24,7 @@ import { registerVoiceConversationHandlers } from './voice-conversation-handlers
import { registerRAGHandlers } from './rag-handlers'
import { registerVoiceActionHandlers } from './voice-action-handlers'
import { registerMeetingModeHandlers } from './meeting-mode-handlers'
import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@ -53,5 +54,6 @@ export function registerAllIpcHandlers(): void {
registerRAGHandlers()
registerVoiceActionHandlers()
registerMeetingModeHandlers()
registerMeetingDocTemplateHandlers()
logger.info('All IPC handlers registered')
}

View file

@ -0,0 +1,63 @@
// src/main/ipc/meeting-doc-template-handlers.ts
// Phase 14.5: Meeting Document Template IPC 핸들러
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError } from '@shared/errors'
import { getMeetingDocTemplateService } from '../services/MeetingDocTemplateService'
import { getLogger } from '../services/LoggerService'
import type {
CreateMeetingDocTemplateParams,
UpdateMeetingDocTemplateParams,
DeleteMeetingDocTemplateParams,
} from '@shared/types'
const logger = getLogger('meeting-doc-template-handlers')
export function registerMeetingDocTemplateHandlers(): void {
const ch = IPC_CHANNELS.MEETING_DOC_TEMPLATE
ipcMain.handle(ch.GET_ALL, () => {
try {
return ipcSuccess(getMeetingDocTemplateService().getAll())
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.CREATE, (_event, params: CreateMeetingDocTemplateParams) => {
try {
const result = getMeetingDocTemplateService().create(params)
return ipcSuccess(result)
} catch (err) {
logger.error(`meetingDocTemplate create 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.UPDATE, (_event, params: UpdateMeetingDocTemplateParams) => {
try {
const result = getMeetingDocTemplateService().update(params)
return ipcSuccess(result)
} catch (err) {
return ipcError(
ErrorCode.MeetingDocTemplateNotFound,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.DELETE, (_event, params: DeleteMeetingDocTemplateParams) => {
try {
getMeetingDocTemplateService().delete(params.id)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(
ErrorCode.MeetingDocTemplateNotFound,
err instanceof Error ? err.message : String(err),
)
}
})
logger.info('Meeting Doc Template IPC handlers registered')
}

View file

@ -1,5 +1,5 @@
// src/main/ipc/meeting-mode-handlers.ts
// Phase 14: Meeting Mode IPC 핸들러
// Phase 14 / 14.5: Meeting Mode IPC 핸들러
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
@ -12,6 +12,8 @@ import type {
MeetingGetSessionParams,
MeetingDeleteSessionParams,
MeetingExportParams,
MeetingGenerateDocParams,
MeetingExportFormat,
} from '@shared/types'
const logger = getLogger('meeting-mode-handlers')
@ -111,5 +113,90 @@ export function registerMeetingModeHandlers(): void {
}
})
// ── Phase 14.5 핸들러 ──
ipcMain.handle(ch.UPDATE_TRANSCRIPT, (_event, params: { sessionId: string; editedTranscript: string }) => {
try {
getMeetingModeService().updateTranscript(params.sessionId, params.editedTranscript)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(
ErrorCode.MeetingTranscriptUpdateFailed,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => {
try {
const doc = await getMeetingModeService().generateDocument(params)
return ipcSuccess(doc)
} catch (err) {
logger.error(`generateDocument 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(
ErrorCode.MeetingDocGenerationFailed,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.GET_DOCUMENTS, (_event, params: { sessionId: string }) => {
try {
return ipcSuccess(getMeetingModeService().getDocuments(params.sessionId))
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.UPDATE_DOCUMENT, (_event, params: { documentId: string; content: string }) => {
try {
getMeetingModeService().updateDocument(params.documentId, params.content)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(
ErrorCode.MeetingDocumentNotFound,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.DELETE_DOCUMENT, (_event, params: { documentId: string }) => {
try {
getMeetingModeService().deleteDocument(params.documentId)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(
ErrorCode.MeetingDocumentNotFound,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat }) => {
try {
const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format)
return ipcSuccess(filePath)
} catch (err) {
logger.error(`exportDocument 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(
ErrorCode.MeetingDocExportFailed,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string }) => {
try {
const filePath = await getMeetingModeService().exportTranscript(params.sessionId)
return ipcSuccess(filePath)
} catch (err) {
logger.error(`exportTranscript 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(
ErrorCode.MeetingExportFailed,
err instanceof Error ? err.message : String(err),
)
}
})
logger.info('Meeting Mode IPC handlers registered')
}

View file

@ -0,0 +1,245 @@
// src/main/services/MeetingDocTemplateService.ts
// Phase 14.5: 회의 문서 템플릿 서비스 (electron-store 기반)
import { EventEmitter } from 'events'
import { nanoid } from 'nanoid'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
import { D3ROError, ErrorCode } from '@shared/errors'
import type {
MeetingDocTemplate,
CreateMeetingDocTemplateParams,
UpdateMeetingDocTemplateParams,
MeetingDocTemplateType,
} from '@shared/types'
const logger = getLogger('MeetingDocTemplateService')
// ── 빌트인 템플릿 ──
const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
{
id: 'builtin-minutes',
name: '회의록',
description: '결정사항, 할 일, 타임라인 구조의 표준 회의록',
templateType: 'minutes' as MeetingDocTemplateType,
systemPrompt: `당신은 전문 회의록 작성 비서입니다.
.
:
-
-
- ( )
:
##
(3-5 )
##
- ( 1)
- ( 2)
##
- [ ] ( ) ( )
##
| | |
|------|------|
| MM:SS | / |`,
isBuiltin: true,
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: 'builtin-report',
name: '보고서',
description: '개요, 핵심 내용, 결론, 제안 구조의 보고서',
templateType: 'report' as MeetingDocTemplateType,
systemPrompt: `당신은 전문 비즈니스 보고서 작성 비서입니다.
.
:
-
-
-
:
##
( 2-3)
##
- ( 1)
- ( 2)
- ( 3)
##
( 2-3)
##
- ( 1)
- ( 2)`,
isBuiltin: true,
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: 'builtin-idea-note',
name: '아이디어 노트',
description: '핵심 아이디어, 장단점, 우선순위, 다음 단계 구조',
templateType: 'idea-note' as MeetingDocTemplateType,
systemPrompt: `당신은 창의적 아이디어 정리 전문가입니다.
.
:
-
-
-
:
##
- ( 1)
- ( 2)
##
- ( 1)
- ( 2)
## /
- ( 1)
- ( 2)
##
1. ( )
2. ( )
##
- [ ] ( 1)
- [ ] ( 2)`,
isBuiltin: true,
createdAt: Date.now(),
updatedAt: Date.now(),
},
]
interface MeetingDocTemplateStoreSchema {
templates: MeetingDocTemplate[]
}
class MeetingDocTemplateService extends EventEmitter {
private _store: Store<MeetingDocTemplateStoreSchema>
constructor() {
super()
this._store = new Store<MeetingDocTemplateStoreSchema>({
name: 'meeting-doc-templates',
defaults: {
templates: [...BUILTIN_TEMPLATES],
},
})
this._ensureBuiltins()
}
// ── CRUD ──
getAll(): MeetingDocTemplate[] {
return this._store.get('templates', [])
}
getById(id: string): MeetingDocTemplate | null {
return this.getAll().find((t) => t.id === id) ?? null
}
create(params: CreateMeetingDocTemplateParams): MeetingDocTemplate {
const template: MeetingDocTemplate = {
id: nanoid(),
name: params.name,
description: params.description,
templateType: 'custom' as MeetingDocTemplateType,
systemPrompt: params.systemPrompt,
isBuiltin: false,
createdAt: Date.now(),
updatedAt: Date.now(),
}
const templates = this.getAll()
templates.push(template)
this._store.set('templates', templates)
logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`)
return template
}
update(params: UpdateMeetingDocTemplateParams): MeetingDocTemplate {
const templates = this.getAll()
const idx = templates.findIndex((t) => t.id === params.id)
if (idx === -1) {
throw new D3ROError(
ErrorCode.MeetingDocTemplateNotFound,
`템플릿을 찾을 수 없습니다: ${params.id}`,
)
}
const existing = templates[idx]
const updated: MeetingDocTemplate = {
...existing,
...(params.name !== undefined && { name: params.name }),
...(params.description !== undefined && { description: params.description }),
...(params.systemPrompt !== undefined && { systemPrompt: params.systemPrompt }),
updatedAt: Date.now(),
}
templates[idx] = updated
this._store.set('templates', templates)
logger.info(`회의 문서 템플릿 수정: ${params.id}`)
return updated
}
delete(id: string): void {
const templates = this.getAll()
const template = templates.find((t) => t.id === id)
if (!template) {
throw new D3ROError(
ErrorCode.MeetingDocTemplateNotFound,
`템플릿을 찾을 수 없습니다: ${id}`,
)
}
if (template.isBuiltin) {
throw new D3ROError(
ErrorCode.MeetingDocTemplateBuiltinDelete,
'빌트인 템플릿은 삭제할 수 없습니다',
)
}
this._store.set(
'templates',
templates.filter((t) => t.id !== id),
)
logger.info(`회의 문서 템플릿 삭제: ${id}`)
}
private _ensureBuiltins(): void {
const templates = this.getAll()
let changed = false
for (const builtin of BUILTIN_TEMPLATES) {
if (!templates.find((t) => t.id === builtin.id)) {
templates.push(builtin)
changed = true
}
}
if (changed) {
this._store.set('templates', templates)
}
}
dispose(): void {
this.removeAllListeners()
}
}
// ── 싱글톤 ──
let instance: MeetingDocTemplateService | null = null
export function getMeetingDocTemplateService(): MeetingDocTemplateService {
if (!instance) {
instance = new MeetingDocTemplateService()
}
return instance
}

View file

@ -1,5 +1,5 @@
// src/main/services/MeetingModeService.ts
// Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록
// Phase 14 / 14.5: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + 문서 생성/내보내기
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
import { EventEmitter } from 'events'
@ -11,9 +11,18 @@ import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { getMainWindow } from '../windows/WindowManager'
import { getDatabase } from '../db'
import { meetingSessions, meetingMemos } from '../db/schema'
import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { D3ROError, ErrorCode } from '@shared/errors'
import {
parseMinutes,
buildExportMarkdown,
markdownToSimpleHtml,
formatTime,
formatDateFile,
formatDateRange,
} from '../utils/meeting-markdown'
import { markdownToDocx } from '../utils/markdown-to-docx'
import type {
MeetingModeState,
MeetingMemo,
@ -25,40 +34,15 @@ import type {
MeetingModeStateInfo,
MeetingProcessingStep,
MeetingProcessingProgress,
MeetingDocument,
MeetingGenerateDocParams,
MeetingExportFormat,
MeetingDocGeneratingProgress,
CaptionSegment,
} from '@shared/types'
const logger = getLogger('MeetingModeService')
const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다.
.
:
- /
- (📝)
-
- ( )
:
##
(3-5 )
##
- ( 1)
- ( 2)
##
- [ ] ( ) ( )
##
| | |
|------|------|
| MM:SS | / |`
/** 긴 회의 청크 분할 기준 (자) */
const LONG_MEETING_THRESHOLD = 20000
const CHUNK_SIZE = 5000
interface MeetingModeServiceEvents {
'state-changed': (state: MeetingModeState) => void
'segment': (segment: CaptionSegment) => void
@ -234,7 +218,7 @@ class MeetingModeService extends EventEmitter {
}).run()
this.emit('memo-added', memo)
logger.debug(`메모 추가: [${this._formatTime(memo.timestampMs)}] ${content}`)
logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`)
return memo
}
@ -302,90 +286,27 @@ class MeetingModeService extends EventEmitter {
}).where(eq(meetingSessions.id, sessionId)).run()
// Step 1: 전사 텍스트 합산
this._sendProgress(sessionId, 'merging', 20)
this._sendProgress(sessionId, 'merging', 40)
const rawTranscript = this._segments
.map((s) => `[${this._formatTime(s.timestamp)}] ${s.text}`)
.map((s) => `[${formatTime(s.timestamp)}] ${s.text}`)
.join('\n')
// Step 2: 메모 포맷팅
const formattedMemos = this._memos
.map((m) => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`)
.join('\n')
// Step 3: LLM 구조화
this._sendProgress(sessionId, 'generating', 40)
const { getLocalLLMService } = await import('./LocalLLMService')
const llmService = getLocalLLMService()
const prompt = this._buildPrompt(rawTranscript, formattedMemos)
const sttEnd = Date.now()
let resultText: string
let resultModel: string
if (rawTranscript.length > LONG_MEETING_THRESHOLD) {
// 긴 회의: 2-pass 요약
const chunks = this._splitIntoChunks(rawTranscript, CHUNK_SIZE)
const chunkResults = []
for (let i = 0; i < chunks.length; i++) {
const pct = 40 + Math.floor((i / chunks.length) * 30)
this._sendProgress(sessionId, 'generating', pct)
const chunkResult = await llmService.generate(
`다음 회의 전사 일부를 3줄로 요약하세요:\n\n${chunks[i]}`,
{ temperature: 0.3 },
)
chunkResults.push(chunkResult.text)
}
const condensed = chunkResults.join('\n\n')
const finalResult = await llmService.generate(
this._buildPrompt(condensed, formattedMemos),
{
systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
temperature: 0.3,
maxTokens: 4096,
},
)
resultText = finalResult.text
resultModel = finalResult.model
} else {
const result = await llmService.generate(prompt, {
systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
temperature: 0.3,
maxTokens: 4096,
})
resultText = result.text
resultModel = result.model
}
const llmEnd = Date.now()
// Step 4: 마크다운 파싱
this._sendProgress(sessionId, 'parsing', 85)
const minutes = this._parseMinutes(resultText)
// Step 5: DB 저장
this._sendProgress(sessionId, 'saving', 90)
// Step 2: DB 저장 (rawTranscript + completed)
this._sendProgress(sessionId, 'saving', 80)
const now = Date.now()
db.update(meetingSessions).set({
status: 'completed',
rawTranscript,
minutesMarkdown: resultText,
minutesJson: JSON.stringify(minutes),
sttModel: configGet('sttModelId') as string | undefined,
llmModel: resultModel,
sttLatencyMs: sttEnd - (this._sessionStartedAt ?? sttEnd),
llmLatencyMs: llmEnd - sttEnd,
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
// 자동 제목 생성
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 알림
// Step 3: 알림
this._sendProgress(sessionId, 'notifying', 95)
new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show()
new Notification({
title: '녹음 완료',
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
}).show()
// 완료 이벤트
this._sendProgress(sessionId, 'notifying', 100)
@ -398,7 +319,7 @@ class MeetingModeService extends EventEmitter {
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
logger.info(`회의록 생성 완료: sessionId=${sessionId}`)
logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`)
} catch (err) {
db.update(meetingSessions).set({
status: 'error',
@ -412,7 +333,7 @@ class MeetingModeService extends EventEmitter {
const d3roErr = new D3ROError(
ErrorCode.MeetingProcessingFailed,
`회의록 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
`후처리 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this.emit('error', d3roErr)
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
@ -509,7 +430,7 @@ class MeetingModeService extends EventEmitter {
const { filePath } = await dialog.showSaveDialog({
title: 'PDF로 내보내기',
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.pdf`,
defaultPath: `회의록_${formatDateFile(session.startedAt)}.pdf`,
filters: [{ name: 'PDF', extensions: ['pdf'] }],
})
if (!filePath) return ''
@ -538,12 +459,12 @@ class MeetingModeService extends EventEmitter {
const { filePath } = await dialog.showSaveDialog({
title: '마크다운으로 내보내기',
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.md`,
defaultPath: `회의록_${formatDateFile(session.startedAt)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
if (!filePath) return ''
const md = this._buildExportMarkdown(session)
const md = buildExportMarkdown(session)
fs.writeFileSync(filePath, md, 'utf-8')
logger.info(`마크다운 내보내기 완료: ${filePath}`)
return filePath
@ -581,80 +502,263 @@ class MeetingModeService extends EventEmitter {
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress)
}
private _formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
// ── Phase 14.5: 전사 수정 ──
private _formatDateFile(epochMs: number): string {
const d = new Date(epochMs)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
}
private _formatDateRange(startedAt: number, endedAt: number | null): string {
const fmt = (ts: number): string => {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
updateTranscript(sessionId: string, editedTranscript: string): void {
const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
if (!row) {
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
}
if (!endedAt) return fmt(startedAt)
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
db.update(meetingSessions).set({
editedTranscript,
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
}
private _buildPrompt(rawTranscript: string, formattedMemos: string): string {
return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.`
}
// ── Phase 14.5: 문서 생성 ──
private _parseMinutes(markdown: string): MeetingMinutes {
const minutes: MeetingMinutes = {
summary: '',
decisions: [],
actionItems: [],
timeline: [],
async generateDocument(params: MeetingGenerateDocParams): Promise<MeetingDocument> {
const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, params.sessionId)).get()
if (!row) {
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${params.sessionId}`)
}
const sections = markdown.split(/^## /m)
for (const section of sections) {
const lines = section.trim().split('\n')
const heading = lines[0]?.toLowerCase() ?? ''
const body = lines.slice(1).join('\n').trim()
const { getMeetingDocTemplateService } = await import('./MeetingDocTemplateService')
const templateService = getMeetingDocTemplateService()
const template = templateService.getById(params.templateId)
if (!template) {
throw new D3ROError(ErrorCode.MeetingDocTemplateNotFound, `템플릿을 찾을 수 없습니다: ${params.templateId}`)
}
if (heading.includes('요약') || heading.includes('summary')) {
minutes.summary = body
} else if (heading.includes('결정') || heading.includes('decision')) {
minutes.decisions = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => l.replace(/^[-*]\s*/, '').trim())
} else if (heading.includes('할 일') || heading.includes('action')) {
minutes.actionItems = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => {
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
return { task: text }
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
const systemPrompt = params.customPrompt ?? template.systemPrompt
const docTitle = params.customTitle ?? `${template.name}${row.title ?? '무제 회의'}`
// 진행률 전송
const sendProgress = (percent: number): void => {
const progress: MeetingDocGeneratingProgress = {
sessionId: params.sessionId,
templateId: params.templateId,
percent,
}
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, progress)
}
sendProgress(10)
const { getLocalLLMService } = await import('./LocalLLMService')
const llmService = getLocalLLMService()
sendProgress(30)
const llmStart = Date.now()
const result = await llmService.generate(
`다음 회의 전사록을 분석하여 문서를 작성해주세요:\n\n${transcript}`,
{
systemPrompt,
temperature: 0.3,
maxTokens: 4096,
},
)
const llmLatencyMs = Date.now() - llmStart
sendProgress(85)
const now = Date.now()
const docId = nanoid()
db.insert(meetingDocuments).values({
id: docId,
sessionId: params.sessionId,
templateType: template.templateType,
title: docTitle,
content: result.text,
promptUsed: systemPrompt,
llmModel: result.model,
llmLatencyMs,
createdAt: now,
updatedAt: now,
}).run()
sendProgress(100)
logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`)
return {
id: docId,
sessionId: params.sessionId,
templateType: template.templateType,
title: docTitle,
content: result.text,
promptUsed: systemPrompt,
llmModel: result.model,
llmLatencyMs,
createdAt: now,
updatedAt: now,
}
}
// ── Phase 14.5: 문서 조회/수정/삭제 ──
getDocuments(sessionId: string): MeetingDocument[] {
const db = getDatabase()
const rows = db.select().from(meetingDocuments)
.where(eq(meetingDocuments.sessionId, sessionId))
.all()
return rows.map((r) => ({
id: r.id,
sessionId: r.sessionId,
templateType: r.templateType,
title: r.title,
content: r.content,
promptUsed: r.promptUsed,
llmModel: r.llmModel,
llmLatencyMs: r.llmLatencyMs,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}))
}
updateDocument(documentId: string, content: string): void {
const db = getDatabase()
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
if (!row) {
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
}
db.update(meetingDocuments).set({ content, updatedAt: Date.now() })
.where(eq(meetingDocuments.id, documentId)).run()
logger.info(`문서 수정: docId=${documentId}`)
}
deleteDocument(documentId: string): void {
const db = getDatabase()
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
if (!row) {
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
}
db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run()
logger.info(`문서 삭제: docId=${documentId}`)
}
// ── Phase 14.5: 문서 내보내기 ──
async exportDocument(documentId: string, format: MeetingExportFormat): Promise<string> {
const db = getDatabase()
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
if (!row) {
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
}
const safeTitle = row.title.replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50)
const dateStr = formatDateFile(row.createdAt)
let filters: Electron.FileFilter[]
let defaultName: string
switch (format) {
case 'md':
filters = [{ name: 'Markdown', extensions: ['md'] }]
defaultName = `${safeTitle}_${dateStr}.md`
break
case 'txt':
filters = [{ name: 'Text', extensions: ['txt'] }]
defaultName = `${safeTitle}_${dateStr}.txt`
break
case 'docx':
filters = [{ name: 'Word Document', extensions: ['docx'] }]
defaultName = `${safeTitle}_${dateStr}.docx`
break
case 'pdf':
filters = [{ name: 'PDF', extensions: ['pdf'] }]
defaultName = `${safeTitle}_${dateStr}.pdf`
break
}
const { filePath } = await dialog.showSaveDialog({
title: '문서 내보내기',
defaultPath: defaultName,
filters,
})
if (!filePath) return ''
switch (format) {
case 'md':
case 'txt':
fs.writeFileSync(filePath, row.content, 'utf-8')
break
case 'docx': {
const buf = await markdownToDocx(row.content, row.title)
fs.writeFileSync(filePath, buf)
break
}
case 'pdf': {
const 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; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h1>${row.title}</h1>
${markdownToSimpleHtml(row.content)}
</body>
</html>`
const win = new BrowserWindow({ show: false, width: 800, height: 600 })
try {
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 },
})
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
const tableRows = body
.split('\n')
.filter((l) => l.includes('|') && !l.includes('---'))
for (const row of tableRows) {
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
if (cells.length >= 2) {
minutes.timeline.push({
time: cells[0],
content: cells[1],
type: cells[1].includes('📝') ? 'memo' : 'transcript',
})
}
fs.writeFileSync(filePath, pdfBuffer)
} finally {
win.close()
}
break
}
}
return minutes
logger.info(`문서 내보내기 완료: format=${format}, path=${filePath}`)
return filePath
}
async exportTranscript(sessionId: string): Promise<string> {
const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
if (!row) {
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
}
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
const dateStr = formatDateFile(row.startedAt)
const safeTitle = (row.title ?? '무제_회의').replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50)
const { filePath } = await dialog.showSaveDialog({
title: '전사 내보내기',
defaultPath: `전사_${safeTitle}_${dateStr}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
})
if (!filePath) return ''
fs.writeFileSync(filePath, transcript, 'utf-8')
logger.info(`전사 내보내기 완료: path=${filePath}`)
return filePath
}
// ── 내부 헬퍼 ──
private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null {
const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
@ -673,6 +777,23 @@ class MeetingModeService extends EventEmitter {
createdAt: m.createdAt,
}))
const docRows = db.select().from(meetingDocuments)
.where(eq(meetingDocuments.sessionId, sessionId))
.all()
const documents: MeetingDocument[] = docRows.map((d) => ({
id: d.id,
sessionId: d.sessionId,
templateType: d.templateType,
title: d.title,
content: d.content,
promptUsed: d.promptUsed,
llmModel: d.llmModel,
llmLatencyMs: d.llmLatencyMs,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
}))
let minutes: MeetingMinutes | null = null
if (row.minutesJson) {
try {
@ -691,27 +812,21 @@ class MeetingModeService extends EventEmitter {
durationMs: row.durationMs,
memoCount: memos.length,
rawTranscript: row.rawTranscript,
editedTranscript: row.editedTranscript,
minutesMarkdown: row.minutesMarkdown,
minutes,
memos,
documents,
sttModel: row.sttModel,
llmModel: row.llmModel,
errorMessage: row.errorMessage,
}
}
private _splitIntoChunks(text: string, chunkSize: number): string[] {
const chunks: string[] = []
for (let i = 0; i < text.length; i += chunkSize) {
chunks.push(text.slice(i, i + chunkSize))
}
return chunks
}
private _buildPdfHtml(session: MeetingSessionDetail): string {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const minutesHtml = session.minutesMarkdown
? this._markdownToSimpleHtml(session.minutesMarkdown)
? markdownToSimpleHtml(session.minutesMarkdown)
: '<p>회의록이 생성되지 않았습니다.</p>'
return `<!DOCTYPE html>
@ -733,53 +848,11 @@ class MeetingModeService extends EventEmitter {
</head>
<body>
<h1>${session.title ?? '무제 회의'}</h1>
<p class="meta">일시: ${this._formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}</p>
<p class="meta">일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}</p>
${minutesHtml}
</body>
</html>`
}
private _buildExportMarkdown(session: MeetingSessionDetail): string {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const memoTable = session.memos.length > 0
? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${this._formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
: ''
return `# 회의록 — ${session.title ?? '무제 회의'}
- ****: ${this._formatDateRange(session.startedAt, session.endedAt)}
- ** **: ${durationMin}
- **STT **: ${session.sttModel ?? '-'}
- **LLM **: ${session.llmModel ?? '-'}
---
${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
---
${memoTable}
---
##
${session.rawTranscript ?? '(없음)'}
`
}
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
private _markdownToSimpleHtml(md: string): string {
return md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/^(?!<[h|l|t|p])/gm, '')
}
}
// ── 싱글톤 ──

View file

@ -0,0 +1,31 @@
// src/main/utils/markdown-to-docx.ts
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
const paragraphs: Paragraph[] = []
// 타이틀 추가
paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE }))
// 줄 단위 파싱
for (const line of markdown.split('\n')) {
if (line.startsWith('## ')) {
paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
} else if (line.startsWith('### ')) {
paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
} else if (line.startsWith('# ')) {
paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
} else if (line.startsWith('- [ ] ')) {
paragraphs.push(new Paragraph({ text: `\u2610 ${line.slice(6)}`, bullet: { level: 0 } }))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
paragraphs.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } }))
} else if (line.trim()) {
paragraphs.push(new Paragraph({ children: [new TextRun(line)] }))
}
}
const doc = new Document({ sections: [{ children: paragraphs }] })
return Buffer.from(await Packer.toBuffer(doc))
}

View file

@ -0,0 +1,116 @@
// src/main/utils/meeting-markdown.ts
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티
import type { MeetingMinutes, MeetingSessionDetail } from '@shared/types'
export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
export function formatDateFile(epochMs: number): string {
const d = new Date(epochMs)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
}
export function formatDateRange(startedAt: number, endedAt: number | null): string {
const fmt = (ts: number): string => {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
if (!endedAt) return fmt(startedAt)
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
}
export function parseMinutes(markdown: string): MeetingMinutes {
const minutes: MeetingMinutes = {
summary: '',
decisions: [],
actionItems: [],
timeline: [],
}
const sections = markdown.split(/^## /m)
for (const section of sections) {
const lines = section.trim().split('\n')
const heading = lines[0]?.toLowerCase() ?? ''
const body = lines.slice(1).join('\n').trim()
if (heading.includes('요약') || heading.includes('summary')) {
minutes.summary = body
} else if (heading.includes('결정') || heading.includes('decision')) {
minutes.decisions = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => l.replace(/^[-*]\s*/, '').trim())
} else if (heading.includes('할 일') || heading.includes('action')) {
minutes.actionItems = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => {
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
return { task: text }
})
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
const tableRows = body
.split('\n')
.filter((l) => l.includes('|') && !l.includes('---'))
for (const row of tableRows) {
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
if (cells.length >= 2) {
minutes.timeline.push({
time: cells[0],
content: cells[1],
type: cells[1].includes('📝') ? 'memo' : 'transcript',
})
}
}
}
}
return minutes
}
export function buildExportMarkdown(session: MeetingSessionDetail): string {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const memoTable = session.memos.length > 0
? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
: ''
return `# 회의록 — ${session.title ?? '무제 회의'}
- ****: ${formatDateRange(session.startedAt, session.endedAt)}
- ** **: ${durationMin}
- **STT **: ${session.sttModel ?? '-'}
- **LLM **: ${session.llmModel ?? '-'}
---
${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
---
${memoTable}
---
##
${session.rawTranscript ?? '(없음)'}
`
}
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
export function markdownToSimpleHtml(md: string): string {
return md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/^(?!<[h|l|t|p])/gm, '')
}

View file

@ -148,6 +148,20 @@ import type {
MeetingExportParams,
MeetingProcessingProgress,
CaptionSegment,
MeetingDocument,
MeetingDocTemplate,
MeetingGenerateDocParams,
MeetingGenerateDocResult,
MeetingUpdateDocParams,
MeetingDeleteDocParams,
MeetingGetDocsParams,
MeetingUpdateTranscriptParams,
MeetingExportDocParams,
MeetingExportFormat,
MeetingDocGeneratingProgress,
CreateMeetingDocTemplateParams,
UpdateMeetingDocTemplateParams,
DeleteMeetingDocTemplateParams,
} from '@shared/types'
import { Feature } from '@shared/types'
import type { IPCResult } from '@shared/errors'
@ -602,6 +616,37 @@ const electronAPI = {
on(IPC_CHANNELS.MEETING_MODE.ERROR, cb),
onAudioLevel: (cb: (e: { level: number }) => void): Unsubscribe =>
on('meetingMode:audioLevel', cb),
// Phase 14.5
updateTranscript: (params: MeetingUpdateTranscriptParams) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.UPDATE_TRANSCRIPT, params),
generateDocument: (params: MeetingGenerateDocParams) =>
invoke<MeetingGenerateDocResult>(IPC_CHANNELS.MEETING_MODE.GENERATE_DOCUMENT, params),
getDocuments: (params: MeetingGetDocsParams) =>
invoke<MeetingDocument[]>(IPC_CHANNELS.MEETING_MODE.GET_DOCUMENTS, params),
updateDocument: (params: MeetingUpdateDocParams) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.UPDATE_DOCUMENT, params),
deleteDocument: (params: MeetingDeleteDocParams) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params),
exportDocument: (params: MeetingExportDocParams) =>
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_DOCUMENT, params),
exportTranscript: (params: { sessionId: string; format: MeetingExportFormat }) =>
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_TRANSCRIPT, params),
editSegment: (params: { sessionId: string; segmentId: string; text: string }) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params),
onDocGeneratingProgress: (cb: (e: MeetingDocGeneratingProgress) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, cb),
},
// ── Meeting Doc Templates (Phase 14.5) ───────────────
meetingDocTemplate: {
getAll: () =>
invoke<MeetingDocTemplate[]>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.GET_ALL),
create: (params: CreateMeetingDocTemplateParams) =>
invoke<MeetingDocTemplate>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.CREATE, params),
update: (params: UpdateMeetingDocTemplateParams) =>
invoke<MeetingDocTemplate>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.UPDATE, params),
delete: (params: DeleteMeetingDocTemplateParams) =>
invoke<void>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, params),
},
} as const

View file

@ -0,0 +1,190 @@
// src/renderer/components/meeting/AddDocumentDialog.tsx
// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트
import { useState, useCallback } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
TextField,
LinearProgress,
} from '@mui/material'
import { MetalCard } from '../ds/MetalCard'
import { PhosphorText } from '../ds/PhosphorText'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '../../theme'
import { useI18n } from '../../i18n'
interface TemplateItem {
id: string
name: string
description: string
templateType: string
isBuiltin: boolean
}
interface AddDocumentDialogProps {
open: boolean
onClose: () => void
onGenerate: (templateId: string, customPrompt?: string, customTitle?: string) => void
templates: TemplateItem[]
generating: boolean
}
export function AddDocumentDialog({
open,
onClose,
onGenerate,
templates,
generating,
}: AddDocumentDialogProps): React.ReactElement {
const { t } = useI18n()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [customPrompt, setCustomPrompt] = useState('')
const [customTitle, setCustomTitle] = useState('')
const selectedTemplate = templates.find((tp) => tp.id === selectedId)
const isCustom = selectedTemplate?.templateType === 'custom'
const handleGenerate = useCallback(() => {
if (!selectedId) return
onGenerate(
selectedId,
isCustom && customPrompt.trim() ? customPrompt.trim() : undefined,
isCustom && customTitle.trim() ? customTitle.trim() : undefined,
)
}, [selectedId, isCustom, customPrompt, customTitle, onGenerate])
const handleClose = useCallback(() => {
if (!generating) {
setSelectedId(null)
setCustomPrompt('')
setCustomTitle('')
onClose()
}
}, [generating, onClose])
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: {
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.inner,
boxShadow: d3roShadow.tooltip,
},
},
}}
>
<DialogTitle>
<PhosphorText variant="label">{t('meeting.selectTemplate')}</PhosphorText>
</DialogTitle>
<DialogContent>
{generating && (
<Box sx={{ mb: 2 }}>
<LinearProgress
sx={{
height: 4,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.5 }}>
{t('meeting.generating')}
</PhosphorText>
</Box>
)}
{/* 템플릿 목록 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{templates.map((tp) => (
<MetalCard
key={tp.id}
onClick={() => !generating && setSelectedId(tp.id)}
sx={{
cursor: generating ? 'default' : 'pointer',
opacity: generating ? 0.6 : 1,
border:
selectedId === tp.id
? `1px solid ${d3roPalette.accent.amber}`
: `1px solid ${d3roPalette.border.subtle}`,
transition: 'border-color 0.15s',
'&:hover': {
borderColor: generating ? undefined : d3roPalette.accent.amberDim,
},
}}
>
<PhosphorText variant="compact">{tp.name}</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.25 }}>
{tp.description}
</PhosphorText>
</MetalCard>
))}
</Box>
{/* 커스텀 템플릿 추가 입력 */}
{isCustom && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
label={t('meeting.templateName')}
size="small"
fullWidth
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
disabled={generating}
sx={{
'& .MuiInputBase-root': { fontSize: d3roTypo.compact.size },
}}
/>
<TextField
label={t('meeting.customPrompt')}
size="small"
fullWidth
multiline
rows={3}
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
disabled={generating}
placeholder={t('meeting.templatePrompt')}
sx={{
'& .MuiInputBase-root': { fontSize: d3roTypo.compact.size },
}}
/>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<PhysicalButton
size="small"
onClick={handleClose}
disabled={generating}
sx={{ height: 36, fontSize: d3roTypo.engrave.size }}
>
{t('common.cancel')}
</PhysicalButton>
<PhysicalButton
size="small"
onClick={handleGenerate}
disabled={!selectedId || generating}
sx={{
height: 36,
fontSize: d3roTypo.engrave.size,
color: d3roPalette.accent.amber,
}}
>
{generating ? t('meeting.generating') : t('meeting.generate')}
</PhysicalButton>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,89 @@
// src/renderer/components/meeting/DocumentTab.tsx
// Phase 14.5: 문서 탭 — MarkdownEditor + 자동저장 + 내보내기
import { useCallback, useRef } from 'react'
import { Box } from '@mui/material'
import DeleteIcon from '@mui/icons-material/Delete'
import { MarkdownEditor } from './MarkdownEditor'
import { ExportMenu } from './ExportMenu'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roTypo } from '../../theme'
import { useI18n } from '../../i18n'
import type { MeetingExportFormat } from '@shared/types'
interface DocumentTabDoc {
id: string
title: string
content: string
templateType: string
}
interface DocumentTabProps {
document: DocumentTabDoc
onContentChange: (content: string) => void
onExport: (format: MeetingExportFormat) => void
onDelete: () => void
}
const AUTOSAVE_DELAY = 500
export function DocumentTab({
document: doc,
onContentChange,
onExport,
onDelete,
}: DocumentTabProps): React.ReactElement {
const { t } = useI18n()
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleChange = useCallback(
(content: string) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
onContentChange(content)
}, AUTOSAVE_DELAY)
},
[onContentChange],
)
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* 콘텐츠 편집기 */}
<Box sx={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<MarkdownEditor
content={doc.content}
onChange={handleChange}
/>
</Box>
{/* 하단 액션 바 */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 1.5,
pt: 1.5,
borderTop: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<ExportMenu onExport={onExport} />
<PhysicalButton
size="small"
color="error"
onClick={onDelete}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
<DeleteIcon sx={{ fontSize: 14, mr: 0.5 }} />
{t('common.delete')}
</PhysicalButton>
</Box>
</Box>
)
}

View file

@ -0,0 +1,143 @@
// src/renderer/components/meeting/EditableSegment.tsx
// Phase 14.5: 전사 세그먼트 인라인 편집 컴포넌트
import { useState, useRef, useCallback } from 'react'
import { Box, TextField, Tooltip } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
import { useI18n } from '../../i18n'
interface EditableSegmentProps {
segmentId: string
timestamp: number // ms, 상대 시간
text: string
edited: boolean // 수정 여부
onEdit: (segmentId: string, newText: string) => void
}
function formatTimestamp(ms: number): string {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
return `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}]`
}
export function EditableSegment({
segmentId,
timestamp,
text,
edited,
onEdit,
}: EditableSegmentProps): React.ReactElement {
const { t } = useI18n()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(text)
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = useCallback(() => {
setDraft(text)
setEditing(true)
}, [text])
const handleCommit = useCallback(() => {
const trimmed = draft.trim()
if (trimmed && trimmed !== text) {
onEdit(segmentId, trimmed)
}
setEditing(false)
}, [draft, text, segmentId, onEdit])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleCommit()
} else if (e.key === 'Escape') {
setEditing(false)
}
},
[handleCommit],
)
if (editing) {
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
pt: '7px',
flexShrink: 0,
userSelect: 'none',
}}
>
{formatTimestamp(timestamp)}
</Box>
<TextField
inputRef={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleCommit}
autoFocus
size="small"
fullWidth
multiline
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
bgcolor: d3roPalette.bg.input,
},
}}
/>
</Box>
)
}
const textNode = (
<Box
component="span"
onClick={handleClick}
sx={{
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
cursor: 'text',
borderBottom: edited ? `1px dashed ${d3roPalette.text.inactive}` : 'none',
'&:hover': {
color: d3roPalette.text.primary,
borderBottomColor: edited ? d3roPalette.accent.amber : undefined,
},
}}
>
{text}
</Box>
)
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
pt: '2px',
flexShrink: 0,
userSelect: 'none',
}}
>
{formatTimestamp(timestamp)}
</Box>
{edited ? (
<Tooltip title={t('meeting.modified')} placement="top">
{textNode}
</Tooltip>
) : (
textNode
)}
</Box>
)
}

View file

@ -0,0 +1,96 @@
// src/renderer/components/meeting/ExportMenu.tsx
// Phase 14.5: 다운로드 형식 선택 드롭다운
import { useState, useCallback } from 'react'
import { Menu, MenuItem } from '@mui/material'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius, d3roShadow } from '../../theme'
import { useI18n } from '../../i18n'
import type { MeetingExportFormat } from '@shared/types'
interface ExportMenuProps {
onExport: (format: MeetingExportFormat) => void
}
interface FormatItem {
format: MeetingExportFormat
label: string
}
export function ExportMenu({ onExport }: ExportMenuProps): React.ReactElement {
const { t } = useI18n()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const open = Boolean(anchorEl)
const handleOpen = useCallback((e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget)
}, [])
const handleClose = useCallback(() => {
setAnchorEl(null)
}, [])
const handleSelect = useCallback(
(format: MeetingExportFormat) => {
onExport(format)
handleClose()
},
[onExport, handleClose],
)
const formats: FormatItem[] = [
{ format: 'md', label: 'Markdown (.md)' },
{ format: 'txt', label: t('meeting.exportTxt') },
{ format: 'pdf', label: 'PDF (.pdf)' },
{ format: 'docx', label: t('meeting.exportDocx') },
]
return (
<>
<PhysicalButton
size="small"
onClick={handleOpen}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
<FileDownloadIcon sx={{ fontSize: 14, mr: 0.5 }} />
{t('meeting.exportFormat')}
</PhysicalButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
slotProps={{
paper: {
sx: {
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
boxShadow: d3roShadow.tooltip,
minWidth: 160,
},
},
}}
>
{formats.map(({ format, label }) => (
<MenuItem
key={format}
onClick={() => handleSelect(format)}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
'&:hover': {
bgcolor: d3roPalette.bg.cardHover,
color: d3roPalette.accent.amber,
},
}}
>
{label}
</MenuItem>
))}
</Menu>
</>
)
}

View file

@ -0,0 +1,83 @@
// src/renderer/components/meeting/MarkdownEditor.tsx
// Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트
import { useState } from 'react'
import { Box } from '@mui/material'
import { MarkdownRenderer } from './MarkdownRenderer'
import { PhysicalButton } from '../ds/PhysicalButton'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../../theme'
import { useI18n } from '../../i18n'
interface MarkdownEditorProps {
content: string
onChange: (content: string) => void
readOnly?: boolean
}
export function MarkdownEditor({
content,
onChange,
readOnly = false,
}: MarkdownEditorProps): React.ReactElement {
const { t } = useI18n()
const [preview, setPreview] = useState(true)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 상단 토글 */}
{!readOnly && (
<Box sx={{ display: 'flex', gap: 0.75, mb: 1 }}>
<PhysicalButton
size="small"
selected={preview}
onClick={() => setPreview(true)}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
{t('meeting.previewMode')}
</PhysicalButton>
<PhysicalButton
size="small"
selected={!preview}
onClick={() => setPreview(false)}
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
>
{t('meeting.editMode')}
</PhysicalButton>
</Box>
)}
{/* 콘텐츠 영역 */}
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{preview || readOnly ? (
<MarkdownRenderer content={content} />
) : (
<Box
component="textarea"
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
sx={{
width: '100%',
height: '100%',
minHeight: 300,
resize: 'none',
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.input,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
boxShadow: d3roShadow.inset,
p: 1.5,
outline: 'none',
lineHeight: 1.7,
boxSizing: 'border-box',
'&:focus': {
borderColor: d3roPalette.accent.amber,
},
}}
/>
)}
</Box>
</Box>
)
}

View file

@ -0,0 +1,198 @@
// src/renderer/components/meeting/MarkdownRenderer.tsx
// Phase 14.5: react-markdown + remark-gfm 래퍼 — d3roPalette 기반 스타일
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
import type { Components } from 'react-markdown'
interface MarkdownRendererProps {
content: string
}
const components: Components = {
h1: ({ children }) => (
<Box
component="h1"
sx={{
fontSize: d3roTypo.value.size,
fontWeight: 600,
color: d3roPalette.accent.amber,
mt: 2,
mb: 1,
borderBottom: `1px solid ${d3roPalette.border.default}`,
pb: 0.5,
}}
>
{children}
</Box>
),
h2: ({ children }) => (
<Box
component="h2"
sx={{
fontSize: d3roTypo.heading.size,
fontWeight: 600,
color: d3roPalette.text.primary,
mt: 1.5,
mb: 0.75,
}}
>
{children}
</Box>
),
h3: ({ children }) => (
<Box
component="h3"
sx={{
fontSize: d3roTypo.body.size,
fontWeight: 600,
color: d3roPalette.text.secondary,
mt: 1,
mb: 0.5,
}}
>
{children}
</Box>
),
p: ({ children }) => (
<Box
component="p"
sx={{
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.7,
mb: 0.75,
mt: 0,
}}
>
{children}
</Box>
),
code: ({ children, className }) => {
const isBlock = className?.startsWith('language-') ?? false
if (isBlock) {
return (
<Box
component="pre"
sx={{
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.small,
p: 1.5,
overflowX: 'auto',
my: 1,
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<Box
component="code"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.accent.amber,
}}
>
{children}
</Box>
</Box>
)
}
return (
<Box
component="code"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
color: d3roPalette.accent.amber,
bgcolor: d3roPalette.bg.inset,
px: 0.5,
py: 0.25,
borderRadius: d3roRadius.xs,
}}
>
{children}
</Box>
)
},
table: ({ children }) => (
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
fontSize: d3roTypo.compact.size,
mb: 1,
'& th, & td': {
border: `1px solid ${d3roPalette.border.default}`,
p: 0.75,
textAlign: 'left',
},
'& th': {
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.secondary,
fontWeight: 600,
},
'& tr:nth-of-type(even)': {
bgcolor: d3roPalette.bg.inset,
},
}}
>
{children}
</Box>
),
li: ({ children }) => (
<Box
component="li"
sx={{
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.7,
mb: 0.25,
}}
>
{children}
</Box>
),
ul: ({ children }) => (
<Box
component="ul"
sx={{ pl: 2.5, mb: 0.75, mt: 0 }}
>
{children}
</Box>
),
ol: ({ children }) => (
<Box
component="ol"
sx={{ pl: 2.5, mb: 0.75, mt: 0 }}
>
{children}
</Box>
),
blockquote: ({ children }) => (
<Box
component="blockquote"
sx={{
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
pl: 1.5,
ml: 0,
my: 0.75,
color: d3roPalette.text.secondary,
fontStyle: 'italic',
}}
>
{children}
</Box>
),
}
export function MarkdownRenderer({ content }: MarkdownRendererProps): React.ReactElement {
return (
<Box sx={{ color: d3roPalette.text.primary, fontSize: d3roTypo.body.size }}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</Box>
)
}

View file

@ -0,0 +1,336 @@
// src/renderer/components/meeting/MeetingDetailTabs.tsx
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
Tabs,
Tab,
IconButton,
TextField,
Tooltip,
} from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import CheckIcon from '@mui/icons-material/Check'
import { TranscriptTab } from './TranscriptTab'
import { DocumentTab } from './DocumentTab'
import { AddDocumentDialog } from './AddDocumentDialog'
import { PhosphorText } from '../ds/PhosphorText'
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
import { useI18n } from '../../i18n'
import type {
MeetingSessionDetail,
MeetingDocument,
MeetingDocTemplate,
MeetingExportFormat,
} from '@shared/types'
interface MeetingDetailTabsProps {
detail: MeetingSessionDetail
onBack: () => void
}
const AUTOSAVE_DELAY = 500
// 세그먼트 파싱: rawTranscript를 줄 단위로 분리
function parseSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): Array<{ id: string; timestamp: number; text: string; edited: boolean }> {
const source = rawTranscript ?? ''
const edited = editedTranscript ?? ''
// [MM:SS] 텍스트 패턴 파싱 시도
const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/
return source
.split('\n')
.filter((line) => line.trim())
.map((line, idx) => {
const m = regex.exec(line.trim())
if (m) {
const min = parseInt(m[1], 10)
const sec = parseInt(m[2], 10)
const text = m[3]
// 수정본이 있으면 edited 판단
const editedLine = edited.split('\n')[idx]?.trim() ?? ''
return {
id: `seg-${idx}`,
timestamp: (min * 60 + sec) * 1000,
text,
edited: Boolean(editedLine) && editedLine !== line.trim(),
}
}
// 타임스탬프 없는 라인: 인덱스 기반
return {
id: `seg-${idx}`,
timestamp: idx * 5000,
text: line.trim(),
edited: false,
}
})
}
export function MeetingDetailTabs({
detail: initialDetail,
onBack,
}: MeetingDetailTabsProps): React.ReactElement {
const { t } = useI18n()
const [detail, setDetail] = useState<MeetingSessionDetail>(initialDetail)
const [documents, setDocuments] = useState<MeetingDocument[]>(initialDetail.documents)
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
const [tabIndex, setTabIndex] = useState(0)
const [dialogOpen, setDialogOpen] = useState(false)
const [generating, setGenerating] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
const autoSaveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 세그먼트 파싱 (rawTranscript 기반)
const segments = parseSegments(detail.rawTranscript, detail.editedTranscript)
// 템플릿 로드
useEffect(() => {
window.electronAPI.meetingDocTemplate.getAll().then((resp) => {
if (resp.success) setTemplates(resp.data)
})
}, [])
// ── 제목 저장 ──
const handleSaveTitle = useCallback(async () => {
if (!titleDraft.trim()) return
await window.electronAPI.meetingMode.updateTitle({
sessionId: detail.id,
title: titleDraft.trim(),
})
setDetail((prev) => ({ ...prev, title: titleDraft.trim() }))
setEditingTitle(false)
}, [detail.id, titleDraft])
// ── 세그먼트 편집 ──
const handleEditSegment = useCallback(
async (segmentId: string, newText: string) => {
await window.electronAPI.meetingMode.editSegment({
sessionId: detail.id,
segmentId,
text: newText,
})
// 로컬 editedTranscript 갱신 (단순 반영)
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
},
[detail.id],
)
// ── 전사 저장 ──
const handleSaveTranscript = useCallback(
async (editedTranscript: string) => {
await window.electronAPI.meetingMode.updateTranscript({
sessionId: detail.id,
editedTranscript,
})
setDetail((prev) => ({ ...prev, editedTranscript }))
},
[detail.id],
)
// ── 문서 내용 자동저장 ──
const handleDocContentChange = useCallback(
(docId: string, content: string) => {
if (autoSaveTimers.current[docId]) clearTimeout(autoSaveTimers.current[docId])
autoSaveTimers.current[docId] = setTimeout(async () => {
await window.electronAPI.meetingMode.updateDocument({
documentId: docId,
content,
})
setDocuments((prev) =>
prev.map((d) => (d.id === docId ? { ...d, content } : d)),
)
}, AUTOSAVE_DELAY)
},
[],
)
// ── 문서 내보내기 ──
const handleExportDocument = useCallback(
async (docId: string, format: MeetingExportFormat) => {
await window.electronAPI.meetingMode.exportDocument({
documentId: docId,
format,
})
},
[],
)
// ── 문서 삭제 ──
const handleDeleteDocument = useCallback(
async (docId: string) => {
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
setDocuments((prev) => {
const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭
setTabIndex((currentTab) => {
if (currentTab === docTabIndex) return 0
if (currentTab > docTabIndex) return currentTab - 1
return currentTab
})
return prev.filter((d) => d.id !== docId)
})
},
[],
)
// ── 문서 생성 ──
const handleGenerate = useCallback(
async (templateId: string, customPrompt?: string, customTitle?: string) => {
setGenerating(true)
const resp = await window.electronAPI.meetingMode.generateDocument({
sessionId: detail.id,
templateId,
customPrompt,
customTitle,
})
setGenerating(false)
if (resp.success) {
const newDoc = resp.data.document
setDocuments((prev) => {
// 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1
setTabIndex(prev.length + 1)
return [...prev, newDoc]
})
setDialogOpen(false)
}
},
[detail.id],
)
// 탭 수: 전사(1) + 문서들 + [+] 버튼(1)
const totalTabCount = 1 + documents.length + 1
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* 헤더 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
flexShrink: 0,
}}
>
<IconButton size="small" onClick={onBack}>
<ArrowBackIcon sx={{ fontSize: 18, color: d3roPalette.text.secondary }} />
</IconButton>
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
<TextField
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
sx={{
flex: 1,
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
}}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle}>
<CheckIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
</IconButton>
</Box>
) : (
<>
<PhosphorText variant="label" sx={{ flex: 1 }}>
{detail.title ?? t('meeting.untitled')}
</PhosphorText>
<Tooltip title={t('meeting.editTitle')}>
<IconButton
size="small"
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
>
<EditIcon sx={{ fontSize: 15, color: d3roPalette.text.inactive }} />
</IconButton>
</Tooltip>
</>
)}
</Box>
{/* 탭 바 */}
<Box sx={{ borderBottom: `1px solid ${d3roPalette.border.subtle}`, flexShrink: 0 }}>
<Tabs
value={tabIndex}
onChange={(_e, v: number) => {
// 마지막 탭([+])은 다이얼로그 열기
if (v === totalTabCount - 1) {
setDialogOpen(true)
return
}
setTabIndex(v)
}}
sx={{
minHeight: 40,
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
minHeight: 40,
py: 0,
color: d3roPalette.text.inactive,
textTransform: 'uppercase',
letterSpacing: '1px',
'&.Mui-selected': { color: d3roPalette.accent.amber },
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 },
}}
>
<Tab label={t('meeting.transcriptTab')} />
{documents.map((doc) => (
<Tab key={doc.id} label={doc.title} />
))}
<Tab
icon={<AddIcon sx={{ fontSize: 16 }} />}
label={t('meeting.addDocument')}
iconPosition="start"
sx={{ minWidth: 120 }}
/>
</Tabs>
</Box>
{/* 탭 콘텐츠 */}
<Box sx={{ flex: 1, overflow: 'auto', p: 2, minHeight: 0 }}>
{tabIndex === 0 && (
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
memos={detail.memos}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
/>
)}
{documents.map((doc, idx) =>
tabIndex === idx + 1 ? (
<DocumentTab
key={doc.id}
document={doc}
onContentChange={(content) => handleDocContentChange(doc.id, content)}
onExport={(format) => handleExportDocument(doc.id, format)}
onDelete={() => handleDeleteDocument(doc.id)}
/>
) : null,
)}
</Box>
{/* 문서 생성 다이얼로그 */}
<AddDocumentDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onGenerate={handleGenerate}
templates={templates}
generating={generating}
/>
</Box>
)
}

View file

@ -0,0 +1,173 @@
// src/renderer/components/meeting/TranscriptTab.tsx
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
import { useState, useCallback } from 'react'
import { Box, Switch, FormControlLabel } from '@mui/material'
import { EditableSegment } from './EditableSegment'
import { PhysicalButton } from '../ds/PhysicalButton'
import { PhosphorText } from '../ds/PhosphorText'
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
import { useI18n } from '../../i18n'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
interface TranscriptTabProps {
sessionId: string
segments: Array<{ id: string; timestamp: number; text: string; edited: boolean }>
rawTranscript: string | null
editedTranscript: string | null
memos: Array<{ id: string; timestampMs: number; content: string }>
onEditSegment: (segmentId: string, newText: string) => void
onSaveTranscript: (editedTranscript: string) => void
}
type TimelineItem =
| { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean }
| { kind: 'memo'; id: string; timestamp: number; content: string }
export function TranscriptTab({
sessionId: _sessionId,
segments,
rawTranscript,
editedTranscript,
memos,
onEditSegment,
onSaveTranscript,
}: TranscriptTabProps): React.ReactElement {
const { t } = useI18n()
const [showEdited, setShowEdited] = useState(true)
const handleDownloadTxt = useCallback(() => {
const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '')
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transcript.txt'
a.click()
URL.revokeObjectURL(url)
}, [showEdited, editedTranscript, rawTranscript])
// 세그먼트 + 메모를 타임스탬프 순으로 인터리브
const timeline: TimelineItem[] = [
...segments.map((s) => ({
kind: 'segment' as const,
id: s.id,
timestamp: s.timestamp,
text: s.text,
edited: s.edited,
})),
...memos.map((m) => ({
kind: 'memo' as const,
id: m.id,
timestamp: m.timestampMs,
content: m.content,
})),
].sort((a, b) => a.timestamp - b.timestamp)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 상단 컨트롤 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<FormControlLabel
control={
<Switch
size="small"
checked={showEdited}
onChange={(e) => {
setShowEdited(e.target.checked)
if (!e.target.checked && rawTranscript) {
onSaveTranscript(rawTranscript)
}
}}
sx={{
'& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.amber },
'& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.amberDim },
}}
/>
}
label={
<PhosphorText variant="dim" sx={{ fontSize: 11 }}>
{showEdited ? t('meeting.editedText') : t('meeting.originalText')}
</PhosphorText>
}
/>
<PhysicalButton
size="small"
onClick={handleDownloadTxt}
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
>
<FileDownloadIcon sx={{ fontSize: 13, mr: 0.5 }} />
{t('meeting.downloadTranscript')}
</PhysicalButton>
</Box>
{/* 타임라인 스크롤 영역 */}
<Box
sx={{
flex: 1,
overflow: 'auto',
fontFamily: d3roFontMono,
lineHeight: 1.8,
}}
>
{timeline.length === 0 ? (
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>
{t('meeting.noSessions')}
</PhosphorText>
) : (
timeline.map((item) => {
if (item.kind === 'segment') {
return (
<EditableSegment
key={item.id}
segmentId={item.id}
timestamp={item.timestamp}
text={item.text}
edited={item.edited}
onEdit={onEditSegment}
/>
)
}
// memo
return (
<Box
key={item.id}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mb: 0.5,
pl: 0.5,
borderLeft: `2px solid ${d3roPalette.accent.amberDim}`,
}}
>
<Box
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: 11,
color: d3roPalette.text.inactive,
flexShrink: 0,
pt: '2px',
}}
>
{`[M ${String(Math.floor(item.timestamp / 60000)).padStart(2, '0')}:${String(Math.floor((item.timestamp % 60000) / 1000)).padStart(2, '0')}]`}
</Box>
<Box
component="span"
sx={{
fontSize: 12,
color: d3roPalette.accent.amber,
fontFamily: d3roFontMono,
}}
>
{item.content}
</Box>
</Box>
)
})
)}
</Box>
</Box>
)
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "Protokoll mit LLM erstellen...",
"meeting.processingStep.parsing": "Protokoll analysieren...",
"meeting.processingStep.saving": "Speichern...",
"meeting.processingStep.notifying": "Benachrichtigung senden..."
"meeting.processingStep.notifying": "Benachrichtigung senden...",
"meeting.transcriptTab": "Transkript",
"meeting.addDocument": "Dokument hinzufügen",
"meeting.selectTemplate": "Vorlage auswählen",
"meeting.generate": "Generieren",
"meeting.generating": "Dokument wird generiert...",
"meeting.editMode": "Bearbeiten",
"meeting.previewMode": "Vorschau",
"meeting.modified": "Geändert",
"meeting.originalText": "Original",
"meeting.editedText": "Bearbeitet",
"meeting.exportDocx": "DOCX exportieren",
"meeting.exportTxt": "TXT exportieren",
"meeting.exportFormat": "Exportieren",
"meeting.customPrompt": "Benutzerdefinierter Prompt",
"meeting.templateName": "Vorlagenname",
"meeting.templatePrompt": "KI-Prompt",
"meeting.templateSave": "Vorlage speichern",
"meeting.documentDeleted": "Dokument gelöscht",
"meeting.autoSaved": "Automatisch gespeichert",
"meeting.minutesTemplate": "Besprechungsprotokoll",
"meeting.reportTemplate": "Bericht",
"meeting.ideaNoteTemplate": "Ideennotiz",
"meeting.customTemplate": "Benutzerdefiniert",
"meeting.downloadTranscript": "Transkript herunterladen"
}

View file

@ -429,5 +429,29 @@
"meeting.processingStep.generating": "Generating minutes with LLM...",
"meeting.processingStep.parsing": "Parsing minutes...",
"meeting.processingStep.saving": "Saving...",
"meeting.processingStep.notifying": "Sending notification..."
"meeting.processingStep.notifying": "Sending notification...",
"meeting.transcriptTab": "Transcript",
"meeting.addDocument": "Add Document",
"meeting.selectTemplate": "Select Template",
"meeting.generate": "Generate",
"meeting.generating": "Generating document...",
"meeting.editMode": "Edit",
"meeting.previewMode": "Preview",
"meeting.modified": "Modified",
"meeting.originalText": "Original",
"meeting.editedText": "Edited",
"meeting.exportDocx": "Export DOCX",
"meeting.exportTxt": "Export TXT",
"meeting.exportFormat": "Export",
"meeting.customPrompt": "Custom Prompt",
"meeting.templateName": "Template Name",
"meeting.templatePrompt": "AI Prompt",
"meeting.templateSave": "Save Template",
"meeting.documentDeleted": "Document deleted",
"meeting.autoSaved": "Auto-saved",
"meeting.minutesTemplate": "Meeting Minutes",
"meeting.reportTemplate": "Report",
"meeting.ideaNoteTemplate": "Idea Note",
"meeting.customTemplate": "Custom",
"meeting.downloadTranscript": "Download Transcript"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "Generando actas con LLM...",
"meeting.processingStep.parsing": "Analizando actas...",
"meeting.processingStep.saving": "Guardando...",
"meeting.processingStep.notifying": "Enviando notificación..."
"meeting.processingStep.notifying": "Enviando notificación...",
"meeting.transcriptTab": "Transcripción",
"meeting.addDocument": "Agregar documento",
"meeting.selectTemplate": "Seleccionar plantilla",
"meeting.generate": "Generar",
"meeting.generating": "Generando documento...",
"meeting.editMode": "Editar",
"meeting.previewMode": "Vista previa",
"meeting.modified": "Modificado",
"meeting.originalText": "Original",
"meeting.editedText": "Editado",
"meeting.exportDocx": "Exportar DOCX",
"meeting.exportTxt": "Exportar TXT",
"meeting.exportFormat": "Exportar",
"meeting.customPrompt": "Prompt personalizado",
"meeting.templateName": "Nombre de plantilla",
"meeting.templatePrompt": "Prompt de IA",
"meeting.templateSave": "Guardar plantilla",
"meeting.documentDeleted": "Documento eliminado",
"meeting.autoSaved": "Guardado automáticamente",
"meeting.minutesTemplate": "Actas de reunión",
"meeting.reportTemplate": "Informe",
"meeting.ideaNoteTemplate": "Nota de ideas",
"meeting.customTemplate": "Personalizado",
"meeting.downloadTranscript": "Descargar transcripción"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "Génération du compte-rendu avec LLM...",
"meeting.processingStep.parsing": "Analyse du compte-rendu...",
"meeting.processingStep.saving": "Enregistrement...",
"meeting.processingStep.notifying": "Envoi de la notification..."
"meeting.processingStep.notifying": "Envoi de la notification...",
"meeting.transcriptTab": "Transcription",
"meeting.addDocument": "Ajouter un document",
"meeting.selectTemplate": "Choisir un modèle",
"meeting.generate": "Générer",
"meeting.generating": "Génération du document...",
"meeting.editMode": "Éditer",
"meeting.previewMode": "Aperçu",
"meeting.modified": "Modifié",
"meeting.originalText": "Original",
"meeting.editedText": "Édité",
"meeting.exportDocx": "Exporter DOCX",
"meeting.exportTxt": "Exporter TXT",
"meeting.exportFormat": "Exporter",
"meeting.customPrompt": "Invite personnalisée",
"meeting.templateName": "Nom du modèle",
"meeting.templatePrompt": "Invite IA",
"meeting.templateSave": "Sauvegarder le modèle",
"meeting.documentDeleted": "Document supprimé",
"meeting.autoSaved": "Sauvegarde automatique",
"meeting.minutesTemplate": "Compte-rendu",
"meeting.reportTemplate": "Rapport",
"meeting.ideaNoteTemplate": "Note d'idées",
"meeting.customTemplate": "Personnalisé",
"meeting.downloadTranscript": "Télécharger la transcription"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "LLMで議事録を生成中...",
"meeting.processingStep.parsing": "議事録を解析中...",
"meeting.processingStep.saving": "保存中...",
"meeting.processingStep.notifying": "通知を送信中..."
"meeting.processingStep.notifying": "通知を送信中...",
"meeting.transcriptTab": "文字起こし",
"meeting.addDocument": "ドキュメント追加",
"meeting.selectTemplate": "テンプレートを選択",
"meeting.generate": "生成",
"meeting.generating": "ドキュメントを生成中...",
"meeting.editMode": "編集",
"meeting.previewMode": "プレビュー",
"meeting.modified": "編集済み",
"meeting.originalText": "原文",
"meeting.editedText": "編集版",
"meeting.exportDocx": "DOCXエクスポート",
"meeting.exportTxt": "TXTエクスポート",
"meeting.exportFormat": "エクスポート",
"meeting.customPrompt": "カスタムプロンプト",
"meeting.templateName": "テンプレート名",
"meeting.templatePrompt": "AIプロンプト",
"meeting.templateSave": "テンプレートを保存",
"meeting.documentDeleted": "ドキュメントが削除されました",
"meeting.autoSaved": "自動保存済み",
"meeting.minutesTemplate": "議事録",
"meeting.reportTemplate": "レポート",
"meeting.ideaNoteTemplate": "アイデアノート",
"meeting.customTemplate": "カスタム",
"meeting.downloadTranscript": "文字起こしをダウンロード"
}

View file

@ -429,5 +429,29 @@
"meeting.processingStep.generating": "LLM 회의록 생성 중...",
"meeting.processingStep.parsing": "회의록 파싱 중...",
"meeting.processingStep.saving": "저장 중...",
"meeting.processingStep.notifying": "완료 알림 중..."
"meeting.processingStep.notifying": "완료 알림 중...",
"meeting.transcriptTab": "전사",
"meeting.addDocument": "문서 추가",
"meeting.selectTemplate": "템플릿 선택",
"meeting.generate": "생성",
"meeting.generating": "문서 생성 중...",
"meeting.editMode": "편집",
"meeting.previewMode": "미리보기",
"meeting.modified": "수정됨",
"meeting.originalText": "원본 보기",
"meeting.editedText": "수정본 보기",
"meeting.exportDocx": "DOCX 내보내기",
"meeting.exportTxt": "TXT 내보내기",
"meeting.exportFormat": "내보내기",
"meeting.customPrompt": "커스텀 프롬프트",
"meeting.templateName": "템플릿 이름",
"meeting.templatePrompt": "AI 프롬프트",
"meeting.templateSave": "템플릿 저장",
"meeting.documentDeleted": "문서가 삭제되었습니다",
"meeting.autoSaved": "자동 저장됨",
"meeting.minutesTemplate": "회의록",
"meeting.reportTemplate": "보고서",
"meeting.ideaNoteTemplate": "아이디어 노트",
"meeting.customTemplate": "커스텀",
"meeting.downloadTranscript": "전사 다운로드"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "Gerando ata com LLM...",
"meeting.processingStep.parsing": "Analisando ata...",
"meeting.processingStep.saving": "Salvando...",
"meeting.processingStep.notifying": "Enviando notificação..."
"meeting.processingStep.notifying": "Enviando notificação...",
"meeting.transcriptTab": "Transcrição",
"meeting.addDocument": "Adicionar documento",
"meeting.selectTemplate": "Selecionar modelo",
"meeting.generate": "Gerar",
"meeting.generating": "Gerando documento...",
"meeting.editMode": "Editar",
"meeting.previewMode": "Visualizar",
"meeting.modified": "Modificado",
"meeting.originalText": "Original",
"meeting.editedText": "Editado",
"meeting.exportDocx": "Exportar DOCX",
"meeting.exportTxt": "Exportar TXT",
"meeting.exportFormat": "Exportar",
"meeting.customPrompt": "Prompt personalizado",
"meeting.templateName": "Nome do modelo",
"meeting.templatePrompt": "Prompt de IA",
"meeting.templateSave": "Salvar modelo",
"meeting.documentDeleted": "Documento excluído",
"meeting.autoSaved": "Salvo automaticamente",
"meeting.minutesTemplate": "Ata de reunião",
"meeting.reportTemplate": "Relatório",
"meeting.ideaNoteTemplate": "Nota de ideias",
"meeting.customTemplate": "Personalizado",
"meeting.downloadTranscript": "Baixar transcrição"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "Создание протокола с помощью LLM...",
"meeting.processingStep.parsing": "Разбор протокола...",
"meeting.processingStep.saving": "Сохранение...",
"meeting.processingStep.notifying": "Отправка уведомления..."
"meeting.processingStep.notifying": "Отправка уведомления...",
"meeting.transcriptTab": "Транскрипция",
"meeting.addDocument": "Добавить документ",
"meeting.selectTemplate": "Выбрать шаблон",
"meeting.generate": "Создать",
"meeting.generating": "Создание документа...",
"meeting.editMode": "Редактировать",
"meeting.previewMode": "Предпросмотр",
"meeting.modified": "Изменено",
"meeting.originalText": "Оригинал",
"meeting.editedText": "Отредактировано",
"meeting.exportDocx": "Экспорт DOCX",
"meeting.exportTxt": "Экспорт TXT",
"meeting.exportFormat": "Экспорт",
"meeting.customPrompt": "Пользовательский запрос",
"meeting.templateName": "Название шаблона",
"meeting.templatePrompt": "ИИ-запрос",
"meeting.templateSave": "Сохранить шаблон",
"meeting.documentDeleted": "Документ удалён",
"meeting.autoSaved": "Автосохранение",
"meeting.minutesTemplate": "Протокол встречи",
"meeting.reportTemplate": "Отчёт",
"meeting.ideaNoteTemplate": "Заметка с идеями",
"meeting.customTemplate": "Пользовательский",
"meeting.downloadTranscript": "Скачать транскрипцию"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "กำลังสร้างรายงานด้วย LLM...",
"meeting.processingStep.parsing": "กำลังวิเคราะห์รายงาน...",
"meeting.processingStep.saving": "กำลังบันทึก...",
"meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน..."
"meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน...",
"meeting.transcriptTab": "การถอดความ",
"meeting.addDocument": "เพิ่มเอกสาร",
"meeting.selectTemplate": "เลือกแม่แบบ",
"meeting.generate": "สร้าง",
"meeting.generating": "กำลังสร้างเอกสาร...",
"meeting.editMode": "แก้ไข",
"meeting.previewMode": "ดูตัวอย่าง",
"meeting.modified": "แก้ไขแล้ว",
"meeting.originalText": "ต้นฉบับ",
"meeting.editedText": "แก้ไขแล้ว",
"meeting.exportDocx": "ส่งออก DOCX",
"meeting.exportTxt": "ส่งออก TXT",
"meeting.exportFormat": "ส่งออก",
"meeting.customPrompt": "พรอมต์กำหนดเอง",
"meeting.templateName": "ชื่อแม่แบบ",
"meeting.templatePrompt": "พรอมต์ AI",
"meeting.templateSave": "บันทึกแม่แบบ",
"meeting.documentDeleted": "ลบเอกสารแล้ว",
"meeting.autoSaved": "บันทึกอัตโนมัติ",
"meeting.minutesTemplate": "รายงานการประชุม",
"meeting.reportTemplate": "รายงาน",
"meeting.ideaNoteTemplate": "บันทึกไอเดีย",
"meeting.customTemplate": "กำหนดเอง",
"meeting.downloadTranscript": "ดาวน์โหลดการถอดความ"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "Đang tạo biên bản với LLM...",
"meeting.processingStep.parsing": "Đang phân tích biên bản...",
"meeting.processingStep.saving": "Đang lưu...",
"meeting.processingStep.notifying": "Đang gửi thông báo..."
"meeting.processingStep.notifying": "Đang gửi thông báo...",
"meeting.transcriptTab": "Bản ghi",
"meeting.addDocument": "Thêm tài liệu",
"meeting.selectTemplate": "Chọn mẫu",
"meeting.generate": "Tạo",
"meeting.generating": "Đang tạo tài liệu...",
"meeting.editMode": "Chỉnh sửa",
"meeting.previewMode": "Xem trước",
"meeting.modified": "Đã chỉnh sửa",
"meeting.originalText": "Bản gốc",
"meeting.editedText": "Đã chỉnh sửa",
"meeting.exportDocx": "Xuất DOCX",
"meeting.exportTxt": "Xuất TXT",
"meeting.exportFormat": "Xuất",
"meeting.customPrompt": "Lệnh tùy chỉnh",
"meeting.templateName": "Tên mẫu",
"meeting.templatePrompt": "Lệnh AI",
"meeting.templateSave": "Lưu mẫu",
"meeting.documentDeleted": "Đã xóa tài liệu",
"meeting.autoSaved": "Đã lưu tự động",
"meeting.minutesTemplate": "Biên bản họp",
"meeting.reportTemplate": "Báo cáo",
"meeting.ideaNoteTemplate": "Ghi chú ý tưởng",
"meeting.customTemplate": "Tùy chỉnh",
"meeting.downloadTranscript": "Tải bản ghi"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "正在以 LLM 產生會議記錄...",
"meeting.processingStep.parsing": "正在解析會議記錄...",
"meeting.processingStep.saving": "正在儲存...",
"meeting.processingStep.notifying": "正在發送通知..."
"meeting.processingStep.notifying": "正在發送通知...",
"meeting.transcriptTab": "轉錄",
"meeting.addDocument": "新增文件",
"meeting.selectTemplate": "選擇範本",
"meeting.generate": "生成",
"meeting.generating": "正在生成文件...",
"meeting.editMode": "編輯",
"meeting.previewMode": "預覽",
"meeting.modified": "已修改",
"meeting.originalText": "原文",
"meeting.editedText": "編輯版",
"meeting.exportDocx": "匯出 DOCX",
"meeting.exportTxt": "匯出 TXT",
"meeting.exportFormat": "匯出",
"meeting.customPrompt": "自訂提示",
"meeting.templateName": "範本名稱",
"meeting.templatePrompt": "AI 提示",
"meeting.templateSave": "儲存範本",
"meeting.documentDeleted": "文件已刪除",
"meeting.autoSaved": "已自動儲存",
"meeting.minutesTemplate": "會議記錄",
"meeting.reportTemplate": "報告",
"meeting.ideaNoteTemplate": "創意筆記",
"meeting.customTemplate": "自訂",
"meeting.downloadTranscript": "下載轉錄"
}

View file

@ -229,5 +229,29 @@
"meeting.processingStep.generating": "正在用 LLM 生成会议纪要...",
"meeting.processingStep.parsing": "正在解析会议纪要...",
"meeting.processingStep.saving": "正在保存...",
"meeting.processingStep.notifying": "正在发送通知..."
"meeting.processingStep.notifying": "正在发送通知...",
"meeting.transcriptTab": "转录",
"meeting.addDocument": "添加文档",
"meeting.selectTemplate": "选择模板",
"meeting.generate": "生成",
"meeting.generating": "正在生成文档...",
"meeting.editMode": "编辑",
"meeting.previewMode": "预览",
"meeting.modified": "已修改",
"meeting.originalText": "原文",
"meeting.editedText": "编辑版",
"meeting.exportDocx": "导出 DOCX",
"meeting.exportTxt": "导出 TXT",
"meeting.exportFormat": "导出",
"meeting.customPrompt": "自定义提示",
"meeting.templateName": "模板名称",
"meeting.templatePrompt": "AI 提示",
"meeting.templateSave": "保存模板",
"meeting.documentDeleted": "文档已删除",
"meeting.autoSaved": "已自动保存",
"meeting.minutesTemplate": "会议记录",
"meeting.reportTemplate": "报告",
"meeting.ideaNoteTemplate": "创意笔记",
"meeting.customTemplate": "自定义",
"meeting.downloadTranscript": "下载转录"
}

View file

@ -4,22 +4,15 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
TextField,
IconButton,
Tooltip,
LinearProgress,
Typography,
TextField,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import StopIcon from '@mui/icons-material/Stop'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import DeleteIcon from '@mui/icons-material/Delete'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import DescriptionIcon from '@mui/icons-material/Description'
import EditIcon from '@mui/icons-material/Edit'
import CheckIcon from '@mui/icons-material/Check'
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
@ -57,8 +50,6 @@ export function MeetingModePage(): React.ReactElement {
// 상세 뷰 상태
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
// ── 세션 목록 로드 ──
const loadSessions = useCallback(async () => {
@ -183,32 +174,6 @@ export function MeetingModePage(): React.ReactElement {
}
}, [])
const handleDelete = useCallback(async () => {
if (!detail) return
await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id })
setDetail(null)
setView('list')
}, [detail])
const handleExportPdf = useCallback(async () => {
if (!detail) return
await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id })
}, [detail])
const handleExportMarkdown = useCallback(async () => {
if (!detail) return
await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id })
}, [detail])
const handleSaveTitle = useCallback(async () => {
if (!detail || !titleDraft.trim()) return
await window.electronAPI.meetingMode.updateTitle({
sessionId: detail.id,
title: titleDraft.trim(),
})
setDetail({ ...detail, title: titleDraft.trim() })
setEditingTitle(false)
}, [detail, titleDraft])
// ── 유틸 ──
const formatTime = (ms: number): string => {
@ -355,17 +320,14 @@ export function MeetingModePage(): React.ReactElement {
}}
>
{segments.map((seg) => (
<Box key={seg.id} sx={{ mb: 0.5 }}>
<Typography
component="span"
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
>
[{formatTime(seg.timestamp - (recordingStartRef.current || seg.timestamp))}]
</Typography>
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
{seg.text}
</Typography>
</Box>
<EditableSegment
key={seg.id}
segmentId={seg.id}
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
text={seg.text}
edited={false}
onEdit={() => { /* 녹음 중 편집 비활성 */ }}
/>
))}
</Box>
</ScreenPanel>
@ -445,156 +407,11 @@ export function MeetingModePage(): React.ReactElement {
// ── 렌더: 상세 뷰 ──
if (view === 'detail' && detail) {
const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
return (
<Box sx={{ p: 3, overflow: 'auto' }}>
{/* 헤더 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<IconButton size="small" onClick={() => { setDetail(null); setView('list') }}>
<ArrowBackIcon sx={{ fontSize: 18 }} />
</IconButton>
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
<TextField
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
sx={{ flex: 1 }}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle}>
<CheckIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
) : (
<>
<PhosphorText variant="label" sx={{ flex: 1 }}>
{detail.title ?? t('meeting.untitled')}
</PhosphorText>
<Tooltip title={t('meeting.editTitle')}>
<IconButton
size="small"
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</>
)}
</Box>
{/* 메타 */}
<PhosphorText variant="dim" sx={{ mb: 3 }}>
{formatDate(detail.startedAt)}
{detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
{' · '}
{t('meeting.duration', { minutes: durationMin })}
</PhosphorText>
{/* 회의록 본문 */}
{detail.minutes && (
<>
{/* 요약 */}
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.summary')}</PhosphorText>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
{detail.minutes.summary}
</PhosphorText>
</MetalCard>
{/* 결정사항 */}
{detail.minutes.decisions.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.decisions')}</PhosphorText>
{detail.minutes.decisions.map((d, i) => (
<PhosphorText key={i} variant="body" sx={{ mb: 0.5 }}>
{'• '}{d}
</PhosphorText>
))}
</MetalCard>
)}
{/* 할 일 */}
{detail.minutes.actionItems.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.actionItems')}</PhosphorText>
{detail.minutes.actionItems.map((item, i) => (
<PhosphorText key={i} variant="body" sx={{ mb: 0.5 }}>
{'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
{item.deadline ? ` (${item.deadline})` : ''}
</PhosphorText>
))}
</MetalCard>
)}
{/* 타임라인 */}
{detail.minutes.timeline.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.timeline')}</PhosphorText>
{detail.minutes.timeline.map((tl, i) => (
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
{tl.time}
</PhosphorText>
<PhosphorText variant="body" sx={{ flex: 1 }}>
{tl.type === 'memo' ? '📝 ' : ''}{tl.content}
</PhosphorText>
</Box>
))}
</MetalCard>
)}
</>
)}
{/* 에러 메시지 */}
{detail.status === 'error' && detail.errorMessage && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.tag.red, mb: 1 }}>
{t('meeting.error')}
</PhosphorText>
<PhosphorText variant="body">{detail.errorMessage}</PhosphorText>
</MetalCard>
)}
{/* 메모 */}
{detail.memos.length > 0 && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.memos')}</PhosphorText>
{detail.memos.map((m) => (
<Box key={m.id} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
{formatTime(m.timestampMs)}
</PhosphorText>
<PhosphorText variant="body">{m.content}</PhosphorText>
</Box>
))}
</MetalCard>
)}
{/* 액션 버튼 */}
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
<Tooltip title={t('meeting.exportPdf')}>
<PhysicalButton size="small" onClick={handleExportPdf}>
<PictureAsPdfIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.exportPdf')}
</PhysicalButton>
</Tooltip>
<Tooltip title={t('meeting.exportMarkdown')}>
<PhysicalButton size="small" onClick={handleExportMarkdown}>
<DescriptionIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.exportMarkdown')}
</PhysicalButton>
</Tooltip>
<Tooltip title={t('meeting.delete')}>
<PhysicalButton size="small" color="error" onClick={handleDelete}>
<DeleteIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.delete')}
</PhysicalButton>
</Tooltip>
</Box>
</Box>
<MeetingDetailTabs
detail={detail}
onBack={() => { setDetail(null); setView('list') }}
/>
)
}

View file

@ -158,6 +158,13 @@ export enum ErrorCode {
MeetingSessionNotFound = 883,
MeetingExportFailed = 884,
MeetingPdfFailed = 885,
MeetingDocumentNotFound = 886,
MeetingDocGenerationFailed = 887,
MeetingDocExportFailed = 888,
MeetingTranscriptUpdateFailed = 889,
MeetingDocTemplateNotFound = 890,
MeetingDocTemplateBuiltinDelete = 891,
MeetingDocxExportFailed = 892,
// === Config (800-849) ===
ConfigReadFailed = 800,

View file

@ -317,14 +317,32 @@ export const IPC_CHANNELS = {
UPDATE_TITLE: 'meetingMode:updateTitle',
EXPORT_PDF: 'meetingMode:exportPdf',
EXPORT_MARKDOWN: 'meetingMode:exportMarkdown',
// Phase 14.5: 문서 생성/편집/내보내기
UPDATE_TRANSCRIPT: 'meetingMode:updateTranscript',
GENERATE_DOCUMENT: 'meetingMode:generateDocument',
GET_DOCUMENTS: 'meetingMode:getDocuments',
UPDATE_DOCUMENT: 'meetingMode:updateDocument',
DELETE_DOCUMENT: 'meetingMode:deleteDocument',
EXPORT_DOCUMENT: 'meetingMode:exportDocument',
EXPORT_TRANSCRIPT: 'meetingMode:exportTranscript',
EDIT_SEGMENT: 'meetingMode:editSegment',
// Main → Renderer events
STATE_CHANGED: 'meetingMode:stateChanged',
SEGMENT: 'meetingMode:segment',
PROCESSING_PROGRESS: 'meetingMode:processingProgress',
SESSION_COMPLETED: 'meetingMode:sessionCompleted',
DOC_GENERATING_PROGRESS: 'meetingMode:docGeneratingProgress',
ERROR: 'meetingMode:error',
},
// ── Phase 14.5: Meeting Document Templates ──
MEETING_DOC_TEMPLATE: {
GET_ALL: 'meetingDocTemplate:getAll',
CREATE: 'meetingDocTemplate:create',
UPDATE: 'meetingDocTemplate:update',
DELETE: 'meetingDocTemplate:delete',
},
// ── Phase 11: License & Monetization ──
LICENSE: {
GET_INFO: 'license:getInfo',

View file

@ -1290,9 +1290,11 @@ export interface MeetingSessionSummary {
export interface MeetingSessionDetail extends MeetingSessionSummary {
rawTranscript: string | null
editedTranscript: string | null
minutesMarkdown: string | null
minutes: MeetingMinutes | null
memos: MeetingMemo[]
documents: MeetingDocument[]
sttModel: string | null
llmModel: string | null
errorMessage: string | null
@ -1358,3 +1360,96 @@ export interface MeetingProcessingProgress {
step: MeetingProcessingStep
percent: number
}
// ============================================================
// Phase 14.5: Meeting Document Types
// ============================================================
export type MeetingDocTemplateType = 'minutes' | 'report' | 'idea-note' | 'custom'
export interface MeetingDocTemplate {
id: string
name: string
description: string
templateType: MeetingDocTemplateType
systemPrompt: string
isBuiltin: boolean
createdAt: number
updatedAt: number
}
export interface MeetingDocument {
id: string
sessionId: string
templateType: MeetingDocTemplateType
title: string
content: string
promptUsed: string | null
llmModel: string | null
llmLatencyMs: number | null
createdAt: number
updatedAt: number
}
export interface MeetingGenerateDocParams {
sessionId: string
templateId: string
customPrompt?: string
customTitle?: string
}
export interface MeetingGenerateDocResult {
document: MeetingDocument
}
export interface MeetingUpdateDocParams {
documentId: string
content: string
}
export interface MeetingDeleteDocParams {
documentId: string
}
export interface MeetingGetDocsParams {
sessionId: string
}
export interface MeetingUpdateTranscriptParams {
sessionId: string
editedTranscript: string
}
export type MeetingExportFormat = 'md' | 'pdf' | 'txt' | 'docx'
export interface MeetingExportDocParams {
documentId: string
format: MeetingExportFormat
}
export interface MeetingExportTranscriptParams {
sessionId: string
}
export interface MeetingDocGeneratingProgress {
sessionId: string
templateId: string
percent: number
}
export interface CreateMeetingDocTemplateParams {
name: string
description: string
systemPrompt: string
}
export interface UpdateMeetingDocTemplateParams {
id: string
name?: string
description?: string
systemPrompt?: string
}
export interface DeleteMeetingDocTemplateParams {
id: string
}