- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
1160 lines
40 KiB
TypeScript
1160 lines
40 KiB
TypeScript
// src/main/services/MeetingModeService.ts
|
|
// Phase 14 / 14.5: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + 문서 생성/내보내기
|
|
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
|
|
|
|
import { EventEmitter } from 'events'
|
|
import { BrowserWindow, Notification, dialog } from 'electron'
|
|
import fs from 'fs'
|
|
import { nanoid } from 'nanoid'
|
|
import { eq, desc, sql } from 'drizzle-orm'
|
|
import { getLogger } from './LoggerService'
|
|
import { configGet, configSet } from './ConfigService'
|
|
import { getMainWindow } from '../windows/WindowManager'
|
|
import { getDatabase } from '../db'
|
|
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,
|
|
MeetingSessionSummary,
|
|
MeetingSessionDetail,
|
|
MeetingMinutes,
|
|
MeetingSessionPage,
|
|
MeetingStartResult,
|
|
MeetingModeStateInfo,
|
|
MeetingProcessingStep,
|
|
MeetingProcessingProgress,
|
|
MeetingDocument,
|
|
MeetingGenerateDocParams,
|
|
MeetingExportFormat,
|
|
MeetingDocGeneratingProgress,
|
|
CaptionSegment,
|
|
} from '@shared/types'
|
|
|
|
const logger = getLogger('MeetingModeService')
|
|
|
|
interface MeetingModeServiceEvents {
|
|
'state-changed': (state: MeetingModeState) => void
|
|
'segment': (segment: CaptionSegment) => void
|
|
'memo-added': (memo: MeetingMemo) => void
|
|
'processing-progress': (progress: MeetingProcessingProgress) => void
|
|
'session-completed': (detail: MeetingSessionDetail) => void
|
|
'error': (error: D3ROError) => void
|
|
}
|
|
|
|
class MeetingModeService extends EventEmitter {
|
|
private _state: MeetingModeState = 'idle'
|
|
private _sessionId: string | null = null
|
|
private _sessionStartedAt: number | null = null
|
|
private _segments: CaptionSegment[] = []
|
|
private _memos: MeetingMemo[] = []
|
|
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
|
|
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
|
private _audioLevelTimer: ReturnType<typeof setInterval> | null = null
|
|
private _lastRms = 0
|
|
/** CaptionService session-saved 방지 플래그 */
|
|
private _meetingModeActive = false
|
|
|
|
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
|
|
private _audioBuffersForFile: Buffer[] = []
|
|
private _audioFilePath: string | null = null
|
|
|
|
// Phase 15: Meeting AI Chat
|
|
private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
|
private _chatAbortController: AbortController | null = null
|
|
|
|
// ── 공개 접근자 ──
|
|
|
|
getState(): MeetingModeState {
|
|
return this._state
|
|
}
|
|
|
|
getStateInfo(): MeetingModeStateInfo {
|
|
return {
|
|
state: this._state,
|
|
sessionId: this._sessionId,
|
|
elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0,
|
|
memoCount: this._memos.length,
|
|
segmentCount: this._segments.length,
|
|
}
|
|
}
|
|
|
|
isMeetingModeActive(): boolean {
|
|
return this._meetingModeActive
|
|
}
|
|
|
|
// ── 녹음 시작 ──
|
|
|
|
async startRecording(): Promise<MeetingStartResult> {
|
|
if (this._state !== 'idle') {
|
|
throw new D3ROError(
|
|
ErrorCode.MeetingAlreadyRecording,
|
|
`회의 모드가 이미 활성 상태입니다: ${this._state}`,
|
|
)
|
|
}
|
|
|
|
// CaptionService 상태 확인
|
|
const { getCaptionService } = await import('./CaptionService')
|
|
const captionService = getCaptionService()
|
|
const captionState = captionService.getState()
|
|
if (captionState !== 'inactive') {
|
|
throw new D3ROError(
|
|
ErrorCode.MeetingAlreadyRecording,
|
|
'자막 모드가 활성 상태입니다. 먼저 자막을 종료해주세요.',
|
|
)
|
|
}
|
|
|
|
this._setState('recording')
|
|
this._meetingModeActive = true
|
|
|
|
const sessionId = nanoid()
|
|
const now = Date.now()
|
|
|
|
this._sessionId = sessionId
|
|
this._sessionStartedAt = now
|
|
this._segments = []
|
|
this._memos = []
|
|
|
|
// DB에 세션 생성
|
|
const db = getDatabase()
|
|
db.insert(meetingSessions).values({
|
|
id: sessionId,
|
|
status: 'recording',
|
|
startedAt: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).run()
|
|
|
|
// CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시)
|
|
this._segmentHandler = (segment: CaptionSegment) => {
|
|
this._segments.push(segment)
|
|
this.emit('segment', segment)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
|
|
}
|
|
captionService.on('segment', this._segmentHandler)
|
|
|
|
// 회의 모드는 마이크 캡처 강제 — ConfigService 값도 임시 변경
|
|
// (CaptionService.start()가 ConfigService에서 다시 읽기 때문)
|
|
type AppConfigKey = keyof import('@shared/types').AppConfig
|
|
const prevAudioSource = configGet('captionAudioSource' as AppConfigKey) as unknown as string
|
|
configSet('captionAudioSource' as AppConfigKey, 'mic' as never)
|
|
|
|
try {
|
|
await captionService.start()
|
|
// 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김
|
|
const { hideCaptionOverlay } = await import('../windows/WindowManager')
|
|
hideCaptionOverlay()
|
|
// ConfigService 원래 값 복원
|
|
if (prevAudioSource) {
|
|
configSet('captionAudioSource' as AppConfigKey, prevAudioSource as never)
|
|
}
|
|
} catch (err) {
|
|
// ConfigService 원래 값 복원
|
|
if (prevAudioSource) {
|
|
configSet('captionAudioSource' as AppConfigKey, prevAudioSource as never)
|
|
}
|
|
// 시작 실패 시 복원
|
|
captionService.off('segment', this._segmentHandler)
|
|
this._segmentHandler = null
|
|
this._meetingModeActive = false
|
|
this._sessionId = null
|
|
this._sessionStartedAt = null
|
|
|
|
db.update(meetingSessions).set({
|
|
status: 'error',
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
this._setState('idle')
|
|
throw err
|
|
}
|
|
|
|
// 오디오 레벨 모니터링 시작
|
|
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
|
|
const audioCaptureService = getAudioCaptureService()
|
|
this._audioBuffersForFile = []
|
|
this._audioDataHandler = (payload: { buffer: Buffer; timestamp: number }) => {
|
|
this._lastRms = calculateRMS(payload.buffer)
|
|
// Phase 15.5-2: 오디오 버퍼 축적 (WAV 저장용)
|
|
this._audioBuffersForFile.push(Buffer.from(payload.buffer))
|
|
}
|
|
audioCaptureService.on('audio-data', this._audioDataHandler)
|
|
|
|
this._audioLevelTimer = setInterval(() => {
|
|
this._sendToRenderer('meetingMode:audioLevel', { level: this._lastRms })
|
|
}, 100)
|
|
|
|
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
|
|
this._sendStateToRenderer()
|
|
|
|
return { sessionId }
|
|
}
|
|
|
|
// ── 메모 추가 ──
|
|
|
|
async addMemo(content: string): Promise<MeetingMemo> {
|
|
if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) {
|
|
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
|
}
|
|
|
|
const memo: MeetingMemo = {
|
|
id: nanoid(),
|
|
sessionId: this._sessionId,
|
|
content,
|
|
timestampMs: Date.now() - this._sessionStartedAt,
|
|
createdAt: Date.now(),
|
|
}
|
|
|
|
this._memos.push(memo)
|
|
|
|
// DB 저장
|
|
const db = getDatabase()
|
|
db.insert(meetingMemos).values({
|
|
id: memo.id,
|
|
sessionId: memo.sessionId,
|
|
content: memo.content,
|
|
timestampMs: memo.timestampMs,
|
|
createdAt: memo.createdAt,
|
|
}).run()
|
|
|
|
this.emit('memo-added', memo)
|
|
logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`)
|
|
|
|
return memo
|
|
}
|
|
|
|
// ── 녹음 종료 ──
|
|
|
|
async stopRecording(): Promise<void> {
|
|
if (this._state !== 'recording') {
|
|
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
|
}
|
|
|
|
const sessionId = this._sessionId!
|
|
const now = Date.now()
|
|
|
|
// 오디오 레벨 모니터링 정리
|
|
if (this._audioLevelTimer) {
|
|
clearInterval(this._audioLevelTimer)
|
|
this._audioLevelTimer = null
|
|
}
|
|
if (this._audioDataHandler) {
|
|
const { getAudioCaptureService } = await import('./AudioCaptureService')
|
|
getAudioCaptureService().off('audio-data', this._audioDataHandler)
|
|
this._audioDataHandler = null
|
|
}
|
|
|
|
// CaptionService 종료
|
|
const { getCaptionService } = await import('./CaptionService')
|
|
const captionService = getCaptionService()
|
|
|
|
if (this._segmentHandler) {
|
|
captionService.off('segment', this._segmentHandler)
|
|
this._segmentHandler = null
|
|
}
|
|
|
|
await captionService.stop()
|
|
|
|
// 세션 종료 시간 기록
|
|
const db = getDatabase()
|
|
db.update(meetingSessions).set({
|
|
endedAt: now,
|
|
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
|
|
updatedAt: now,
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
|
|
|
|
// 후처리 시작 (비동기)
|
|
this._runPostProcessing().catch((err: unknown) => {
|
|
logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
|
})
|
|
}
|
|
|
|
// ── 후처리 파이프라인 ──
|
|
|
|
private async _runPostProcessing(): Promise<void> {
|
|
this._setState('processing')
|
|
this._sendStateToRenderer()
|
|
const sessionId = this._sessionId!
|
|
const db = getDatabase()
|
|
|
|
try {
|
|
db.update(meetingSessions).set({
|
|
status: 'processing',
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
// Step 1: 전사 텍스트 합산
|
|
this._sendProgress(sessionId, 'merging', 40)
|
|
const rawTranscript = this._segments
|
|
.map((s) => `[${formatTime(s.timestamp)}] ${s.text}`)
|
|
.join('\n')
|
|
|
|
// Step 2: DB 저장 (rawTranscript + completed)
|
|
this._sendProgress(sessionId, 'saving', 80)
|
|
const now = Date.now()
|
|
db.update(meetingSessions).set({
|
|
status: 'completed',
|
|
rawTranscript,
|
|
sttModel: configGet('sttModelId') as string | undefined,
|
|
updatedAt: now,
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
|
if (this._audioBuffersForFile.length > 0) {
|
|
try {
|
|
const { app } = await import('electron')
|
|
const path = await import('path')
|
|
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
|
|
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
|
|
const wavPath = path.join(audioDir, `${sessionId}.wav`)
|
|
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
|
|
this._audioFilePath = wavPath
|
|
logger.info(`회의 오디오 저장: ${wavPath}`)
|
|
} catch (err) {
|
|
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
}
|
|
this._audioBuffersForFile = []
|
|
|
|
// Step 3: 알림
|
|
this._sendProgress(sessionId, 'notifying', 95)
|
|
new Notification({
|
|
title: '녹음 완료',
|
|
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
|
|
}).show()
|
|
|
|
// 완료 이벤트
|
|
this._sendProgress(sessionId, 'notifying', 100)
|
|
const detail = this._loadSessionDetail(sessionId)
|
|
if (detail) {
|
|
this.emit('session-completed', detail)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail)
|
|
}
|
|
|
|
this._resetSession()
|
|
this._setState('idle')
|
|
this._sendStateToRenderer()
|
|
logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`)
|
|
} catch (err) {
|
|
db.update(meetingSessions).set({
|
|
status: 'error',
|
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
this._resetSession()
|
|
this._setState('idle')
|
|
this._sendStateToRenderer()
|
|
|
|
const d3roErr = new D3ROError(
|
|
ErrorCode.MeetingProcessingFailed,
|
|
`후처리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
|
)
|
|
this.emit('error', d3roErr)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
|
|
code: d3roErr.code,
|
|
message: d3roErr.message,
|
|
})
|
|
logger.error(`후처리 실패: ${d3roErr.message}`)
|
|
}
|
|
}
|
|
|
|
// ── 세션 조회 ──
|
|
|
|
getSessions(page: number, pageSize: number): MeetingSessionPage {
|
|
const db = getDatabase()
|
|
const offset = (page - 1) * pageSize
|
|
|
|
const countResult = db.select({ count: sql<number>`count(*)` })
|
|
.from(meetingSessions)
|
|
.get()
|
|
const total = countResult?.count ?? 0
|
|
|
|
const rows = db.select()
|
|
.from(meetingSessions)
|
|
.orderBy(desc(meetingSessions.createdAt))
|
|
.limit(pageSize)
|
|
.offset(offset)
|
|
.all()
|
|
|
|
const sessions: MeetingSessionSummary[] = rows.map((row) => {
|
|
const memoCount = db.select({ count: sql<number>`count(*)` })
|
|
.from(meetingMemos)
|
|
.where(eq(meetingMemos.sessionId, row.id))
|
|
.get()?.count ?? 0
|
|
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
status: row.status as MeetingSessionSummary['status'],
|
|
startedAt: row.startedAt,
|
|
endedAt: row.endedAt,
|
|
durationMs: row.durationMs,
|
|
memoCount,
|
|
}
|
|
})
|
|
|
|
return {
|
|
sessions,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
}
|
|
}
|
|
|
|
getSession(sessionId: string): MeetingSessionDetail {
|
|
const detail = this._loadSessionDetail(sessionId)
|
|
if (!detail) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
return detail
|
|
}
|
|
|
|
deleteSession(sessionId: 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}`)
|
|
}
|
|
|
|
db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run()
|
|
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
|
logger.info(`회의 세션 삭제: ${sessionId}`)
|
|
}
|
|
|
|
updateTitle(sessionId: string, title: 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}`)
|
|
}
|
|
db.update(meetingSessions).set({ title, updatedAt: Date.now() })
|
|
.where(eq(meetingSessions.id, sessionId)).run()
|
|
}
|
|
|
|
// ── 내보내기 ──
|
|
|
|
async exportPdf(sessionId: string): Promise<string> {
|
|
const session = this._loadSessionDetail(sessionId)
|
|
if (!session) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
|
|
const html = this._buildPdfHtml(session)
|
|
|
|
const { filePath } = await dialog.showSaveDialog({
|
|
title: 'PDF로 내보내기',
|
|
defaultPath: `회의록_${formatDateFile(session.startedAt)}.pdf`,
|
|
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
|
})
|
|
if (!filePath) return ''
|
|
|
|
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 },
|
|
})
|
|
fs.writeFileSync(filePath, pdfBuffer)
|
|
logger.info(`PDF 내보내기 완료: ${filePath}`)
|
|
return filePath
|
|
} finally {
|
|
win.close()
|
|
}
|
|
}
|
|
|
|
async exportMarkdown(sessionId: string): Promise<string> {
|
|
const session = this._loadSessionDetail(sessionId)
|
|
if (!session) {
|
|
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
|
}
|
|
|
|
const { filePath } = await dialog.showSaveDialog({
|
|
title: '마크다운으로 내보내기',
|
|
defaultPath: `회의록_${formatDateFile(session.startedAt)}.md`,
|
|
filters: [{ name: 'Markdown', extensions: ['md'] }],
|
|
})
|
|
if (!filePath) return ''
|
|
|
|
const md = buildExportMarkdown(session)
|
|
fs.writeFileSync(filePath, md, 'utf-8')
|
|
logger.info(`마크다운 내보내기 완료: ${filePath}`)
|
|
return filePath
|
|
}
|
|
|
|
// ── 내부 헬퍼 ──
|
|
|
|
private _setState(state: MeetingModeState): void {
|
|
this._state = state
|
|
this.emit('state-changed', state)
|
|
}
|
|
|
|
private _resetSession(): void {
|
|
this._sessionId = null
|
|
this._sessionStartedAt = null
|
|
this._segments = []
|
|
this._memos = []
|
|
this._meetingModeActive = false
|
|
this._audioBuffersForFile = []
|
|
this._audioFilePath = null
|
|
}
|
|
|
|
/** PCM16 버퍼를 WAV 파일로 저장 (16kHz mono 16-bit) */
|
|
private _saveWav(filePath: string, pcmBuffer: Buffer): void {
|
|
const sampleRate = 16000
|
|
const numChannels = 1
|
|
const bitsPerSample = 16
|
|
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
|
|
const blockAlign = numChannels * (bitsPerSample / 8)
|
|
const dataSize = pcmBuffer.length
|
|
|
|
const header = Buffer.alloc(44)
|
|
header.write('RIFF', 0)
|
|
header.writeUInt32LE(36 + dataSize, 4)
|
|
header.write('WAVE', 8)
|
|
header.write('fmt ', 12)
|
|
header.writeUInt32LE(16, 16) // fmt chunk size
|
|
header.writeUInt16LE(1, 20) // PCM
|
|
header.writeUInt16LE(numChannels, 22)
|
|
header.writeUInt32LE(sampleRate, 24)
|
|
header.writeUInt32LE(byteRate, 28)
|
|
header.writeUInt16LE(blockAlign, 32)
|
|
header.writeUInt16LE(bitsPerSample, 34)
|
|
header.write('data', 36)
|
|
header.writeUInt32LE(dataSize, 40)
|
|
|
|
fs.writeFileSync(filePath, Buffer.concat([header, pcmBuffer]))
|
|
}
|
|
|
|
private _sendToRenderer(channel: string, data: unknown): void {
|
|
const win = getMainWindow()
|
|
if (win && !win.isDestroyed()) {
|
|
win.webContents.send(channel, data)
|
|
}
|
|
}
|
|
|
|
private _sendStateToRenderer(): void {
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, this.getStateInfo())
|
|
}
|
|
|
|
private _sendProgress(sessionId: string, step: MeetingProcessingStep, percent: number): void {
|
|
const progress: MeetingProcessingProgress = { sessionId, step, percent }
|
|
this.emit('processing-progress', progress)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress)
|
|
}
|
|
|
|
// ── Phase 14.5: 전사 수정 ──
|
|
|
|
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}`)
|
|
}
|
|
db.update(meetingSessions).set({
|
|
editedTranscript,
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
|
|
}
|
|
|
|
// ── Phase 14.5: 문서 생성 ──
|
|
|
|
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 { getMeetingDocTemplateService } = await import('./MeetingDocTemplateService')
|
|
const templateService = getMeetingDocTemplateService()
|
|
const template = templateService.getById(params.templateId)
|
|
if (!template) {
|
|
throw new D3ROError(ErrorCode.MeetingDocTemplateNotFound, `템플릿을 찾을 수 없습니다: ${params.templateId}`)
|
|
}
|
|
|
|
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
|
|
|
// 메모도 함께 참조
|
|
const memoRows = db.select().from(meetingMemos)
|
|
.where(eq(meetingMemos.sessionId, params.sessionId))
|
|
.orderBy(meetingMemos.timestampMs)
|
|
.all()
|
|
const formattedMemos = memoRows.length > 0
|
|
? memoRows.map((m) => `[${formatTime(m.timestampMs)}] 📝 ${m.content}`).join('\n')
|
|
: ''
|
|
|
|
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 memoSection = formattedMemos
|
|
? `\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}`
|
|
: ''
|
|
const result = await llmService.generate(
|
|
`다음 회의 전사록과 참석자 메모를 분석하여 문서를 작성해주세요:\n\n## 전사록\n${transcript}${memoSection}`,
|
|
{
|
|
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 },
|
|
})
|
|
fs.writeFileSync(filePath, pdfBuffer)
|
|
} finally {
|
|
win.close()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
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()
|
|
if (!row) return null
|
|
|
|
const memoRows = db.select().from(meetingMemos)
|
|
.where(eq(meetingMemos.sessionId, sessionId))
|
|
.orderBy(meetingMemos.timestampMs)
|
|
.all()
|
|
|
|
const memos: MeetingMemo[] = memoRows.map((m) => ({
|
|
id: m.id,
|
|
sessionId: m.sessionId,
|
|
content: m.content,
|
|
timestampMs: m.timestampMs,
|
|
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 {
|
|
minutes = JSON.parse(row.minutesJson) as MeetingMinutes
|
|
} catch {
|
|
logger.warn(`회의록 JSON 파싱 실패: sessionId=${sessionId}`)
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
status: row.status as MeetingSessionDetail['status'],
|
|
startedAt: row.startedAt,
|
|
endedAt: row.endedAt,
|
|
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,
|
|
}
|
|
}
|
|
|
|
// ── Phase 15: Auto Polish ──
|
|
|
|
async polishTranscript(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.rawTranscript
|
|
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
|
|
|
|
const { getLocalLLMService } = await import('./LocalLLMService')
|
|
const llm = getLocalLLMService()
|
|
const result = await llm.generate(transcript, {
|
|
systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
|
temperature: 0.3,
|
|
})
|
|
|
|
db.update(meetingSessions).set({
|
|
editedTranscript: result.text,
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
|
|
return result.text
|
|
}
|
|
|
|
// ── Phase 15: Meeting AI Chat ──
|
|
|
|
async chatWithMeeting(sessionId: string, userMessage: string): Promise<void> {
|
|
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 systemPrompt = `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
|
|
아래 회의 전사 내용을 참고하여 사용자의 질문에 정확하게 답변하세요.
|
|
전사 내용에 없는 것은 "전사 내용에서 확인되지 않습니다"라고 답하세요.
|
|
|
|
## 회의 전사
|
|
${transcript}`
|
|
|
|
this._chatHistory.push({ role: 'user', content: userMessage })
|
|
|
|
const { getLocalLLMService } = await import('./LocalLLMService')
|
|
const llm = getLocalLLMService()
|
|
|
|
const messages = [
|
|
{ role: 'system' as const, content: systemPrompt },
|
|
...this._chatHistory,
|
|
]
|
|
|
|
let fullResponse = ''
|
|
this._chatAbortController = new AbortController()
|
|
const signal = this._chatAbortController.signal
|
|
|
|
try {
|
|
const generator = llm.chatStream(messages, { temperature: 0.5 })
|
|
|
|
for await (const token of generator) {
|
|
if (signal.aborted) break
|
|
fullResponse += token
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { token })
|
|
}
|
|
|
|
this._chatHistory.push({ role: 'assistant', content: fullResponse })
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, {
|
|
role: 'assistant',
|
|
content: fullResponse,
|
|
timestamp: Date.now(),
|
|
})
|
|
} catch (err) {
|
|
if ((err as Error).name !== 'AbortError') {
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, {
|
|
message: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
} finally {
|
|
this._chatAbortController = null
|
|
}
|
|
}
|
|
|
|
cancelMeetingChat(): void {
|
|
if (this._chatAbortController) {
|
|
this._chatAbortController.abort()
|
|
this._chatAbortController = null
|
|
}
|
|
}
|
|
|
|
clearMeetingChatHistory(): void {
|
|
this._chatHistory = []
|
|
}
|
|
|
|
// ── Phase 15.5: 화자 구분 ──
|
|
|
|
async diarizeSession(sessionId: string, numSpeakers?: number): Promise<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 (!row.rawTranscript) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다')
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
|
|
|
|
// 오디오 파일 존재 확인
|
|
const { app } = await import('electron')
|
|
const path = await import('path')
|
|
const audioPath = path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
|
|
const hasAudioFile = fs.existsSync(audioPath)
|
|
|
|
const hfToken = configGet('hfToken') as string
|
|
|
|
if (hasAudioFile && hfToken) {
|
|
// Phase 15.5-2: pyannote 실제 화자 구분
|
|
logger.info(`pyannote 화자 구분 시작: ${audioPath}`)
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 20 })
|
|
|
|
try {
|
|
const audioBuffer = fs.readFileSync(audioPath)
|
|
const serverUrl = `http://127.0.0.1:18765`
|
|
|
|
const formData = new FormData()
|
|
formData.append('audio', new Blob([audioBuffer]), 'audio.wav')
|
|
formData.append('hf_token', hfToken)
|
|
if (numSpeakers && numSpeakers > 0) {
|
|
formData.append('num_speakers', String(numSpeakers))
|
|
}
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 40 })
|
|
|
|
const response = await fetch(`${serverUrl}/diarize`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.json() as { message?: string }
|
|
throw new Error(errData.message ?? `Diarization failed: ${response.status}`)
|
|
}
|
|
|
|
const diarResult = await response.json() as {
|
|
segments: Array<{ speaker: string; start: number; end: number }>
|
|
num_speakers: number
|
|
}
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 })
|
|
|
|
// 전사 세그먼트와 화자 세그먼트 매칭
|
|
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
|
const lines = transcript.split('\n').filter((l) => l.trim())
|
|
const diarSegments = diarResult.segments
|
|
|
|
const labeledLines = lines.map((line) => {
|
|
const timeMatch = /^\[(\d{2}):(\d{2})\]/.exec(line)
|
|
if (!timeMatch) return line
|
|
|
|
const timeSec = parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10)
|
|
// 해당 시간에 가장 가까운 화자 세그먼트 찾기
|
|
const matchedSeg = diarSegments.find(
|
|
(s) => timeSec >= s.start && timeSec <= s.end,
|
|
)
|
|
if (matchedSeg) {
|
|
const speakerLabel = matchedSeg.speaker.replace('SPEAKER_', '화자 ')
|
|
return line.replace(/^\[(\d{2}:\d{2})\]/, `[$1] [${speakerLabel}]`)
|
|
}
|
|
return line
|
|
})
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
|
|
|
|
db.update(meetingSessions).set({
|
|
editedTranscript: labeledLines.join('\n'),
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
|
|
logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`)
|
|
} catch (err) {
|
|
logger.warn(`pyannote 실패, LLM 폴백: ${err instanceof Error ? err.message : String(err)}`)
|
|
// pyannote 실패 시 LLM 폴백
|
|
await this._diarizeLLMFallback(sessionId, row, numSpeakers)
|
|
}
|
|
} else {
|
|
// LLM 기반 화자 추정 (오디오 없거나 HF 토큰 없음)
|
|
await this._diarizeLLMFallback(sessionId, row, numSpeakers)
|
|
}
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 100 })
|
|
logger.info(`화자 구분 완료: sessionId=${sessionId}`)
|
|
}
|
|
|
|
private async _diarizeLLMFallback(
|
|
sessionId: string,
|
|
row: { rawTranscript: string | null; editedTranscript: string | null },
|
|
numSpeakers?: number,
|
|
): Promise<void> {
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 30 })
|
|
|
|
const { getLocalLLMService } = await import('./LocalLLMService')
|
|
const llm = getLocalLLMService()
|
|
|
|
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
|
const speakerHint = numSpeakers && numSpeakers > 0
|
|
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
|
|
: '화자 수는 문맥에서 추정하세요.'
|
|
|
|
const result = await llm.generate(transcript, {
|
|
systemPrompt: `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요.
|
|
${speakerHint}
|
|
각 줄을 [시간] [화자] 내용 형식으로 변환하세요.
|
|
화자는 "화자 1", "화자 2" 등으로 표시하세요.
|
|
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
|
|
원문의 타임스탬프와 내용은 변경하지 마세요.`,
|
|
temperature: 0.3,
|
|
maxTokens: 8192,
|
|
})
|
|
|
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
|
|
|
|
const db = getDatabase()
|
|
db.update(meetingSessions).set({
|
|
editedTranscript: result.text,
|
|
updatedAt: Date.now(),
|
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
|
}
|
|
|
|
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
|
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
|
const minutesHtml = session.minutesMarkdown
|
|
? markdownToSimpleHtml(session.minutesMarkdown)
|
|
: '<p>회의록이 생성되지 않았습니다.</p>'
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
|
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
|
|
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
|
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
|
th { background: #f5f5f5; font-weight: 600; }
|
|
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
|
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
|
|
ul { padding-left: 20px; }
|
|
li { margin: 4px 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${session.title ?? '무제 회의'}</h1>
|
|
<p class="meta">일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
|
|
${minutesHtml}
|
|
</body>
|
|
</html>`
|
|
}
|
|
}
|
|
|
|
// ── 싱글톤 ──
|
|
let instance: MeetingModeService | null = null
|
|
|
|
export function getMeetingModeService(): MeetingModeService {
|
|
if (!instance) {
|
|
instance = new MeetingModeService()
|
|
}
|
|
return instance
|
|
}
|