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:
parent
6197ceb132
commit
d4928ffa60
38 changed files with 4791 additions and 438 deletions
|
|
@ -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, '')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue